part of 'reader.dart'; class _ReaderImages extends StatefulWidget { const _ReaderImages({super.key}); @override State<_ReaderImages> createState() => _ReaderImagesState(); } class _ReaderImagesState extends State<_ReaderImages> { String? error; bool inProgress = false; late _ReaderState reader; @override void initState() { reader = context.reader; reader.isLoading = true; super.initState(); } void load() async { if (inProgress) return; inProgress = true; if (reader.type == ComicType.local || (LocalManager() .isDownloaded(reader.cid, reader.type, reader.chapter))) { try { var images = await LocalManager() .getImages(reader.cid, reader.type, reader.chapter); setState(() { reader.images = images; reader.isLoading = false; inProgress = false; }); } catch (e) { setState(() { error = e.toString(); reader.isLoading = false; inProgress = false; }); } } else { var res = await reader.type.comicSource!.loadComicPages!( reader.widget.cid, reader.widget.chapters?.ids.elementAt(reader.chapter - 1), ); if (res.error) { setState(() { error = res.errorMessage; reader.isLoading = false; inProgress = false; }); } else { setState(() { reader.images = res.data; reader.isLoading = false; inProgress = false; }); } } context.readerScaffold.update(); } @override Widget build(BuildContext context) { if (reader.isLoading) { load(); return const Center( child: CircularProgressIndicator(), ); } else if (error != null) { return NetworkError( message: error!, retry: () { setState(() { reader.isLoading = true; error = null; }); }, ); } else { if (reader.mode.isGallery) { return _GalleryMode( key: Key('${reader.mode.key}_${reader.imagesPerPage}')); } else { return _ContinuousMode(key: Key(reader.mode.key)); } } } } class _GalleryMode extends StatefulWidget { const _GalleryMode({super.key}); @override State<_GalleryMode> createState() => _GalleryModeState(); } class _GalleryModeState extends State<_GalleryMode> implements _ImageViewController { late PageController controller; late List cached; int get preCacheCount => appdata.settings["preloadImageCount"]; var photoViewControllers = {}; late _ReaderState reader; int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil(); @override void initState() { reader = context.reader; controller = PageController(initialPage: reader.page); reader._imageViewController = this; cached = List.filled(reader.maxPage + 2, false); Future.microtask(() { context.readerScaffold.setFloatingButton(0); }); super.initState(); } void cache(int current) { for (int i = current + 1; i <= current + preCacheCount; i++) { if (i <= totalPages && !cached[i]) { int startIndex = (i - 1) * reader.imagesPerPage; int endIndex = math.min(startIndex + reader.imagesPerPage, reader.images!.length); for (int i = startIndex; i < endIndex; i++) { precacheImage( _createImageProviderFromKey(reader.images![i], context), context); } cached[i] = true; } } } @override Widget build(BuildContext context) { return PhotoViewGallery.builder( backgroundDecoration: BoxDecoration( color: context.colorScheme.surface, ), reverse: reader.mode == ReaderMode.galleryRightToLeft, scrollDirection: reader.mode == ReaderMode.galleryTopToBottom ? Axis.vertical : Axis.horizontal, itemCount: totalPages + 2, builder: (BuildContext context, int index) { if (index == 0 || index == totalPages + 1) { return PhotoViewGalleryPageOptions.customChild( child: const SizedBox(), ); } else { int pageIndex = index - 1; int startIndex = pageIndex * reader.imagesPerPage; int endIndex = math.min( startIndex + reader.imagesPerPage, reader.images!.length); List pageImages = reader.images!.sublist(startIndex, endIndex); cached[index] = true; cache(index); photoViewControllers[index] ??= PhotoViewController(); if (reader.imagesPerPage == 1) { return PhotoViewGalleryPageOptions( filterQuality: FilterQuality.medium, controller: photoViewControllers[index], imageProvider: _createImageProviderFromKey(pageImages[0], context), fit: BoxFit.contain, errorBuilder: (_, error, s, retry) { return NetworkError(message: error.toString(), retry: retry); }, ); } return PhotoViewGalleryPageOptions.customChild( controller: photoViewControllers[index], minScale: PhotoViewComputedScale.contained * 1.0, maxScale: PhotoViewComputedScale.covered * 10.0, child: buildPageImages(pageImages), ); } }, pageController: controller, loadingBuilder: (context, event) => Center( child: SizedBox( width: 20.0, height: 20.0, child: CircularProgressIndicator( backgroundColor: context.colorScheme.surfaceContainerHigh, value: event == null || event.expectedTotalBytes == null ? null : event.cumulativeBytesLoaded / event.expectedTotalBytes!, ), ), ), onPageChanged: (i) { if (i == 0) { if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) { reader.toPage(1); } } else if (i == totalPages + 1) { if (reader.isLastChapterOfGroup || !reader.toNextChapter()) { reader.toPage(totalPages); } } else { reader.setPage(i); context.readerScaffold.update(); } }, ); } Widget buildPageImages(List images) { Axis axis = (reader.mode == ReaderMode.galleryTopToBottom) ? Axis.vertical : Axis.horizontal; bool reverse = reader.mode == ReaderMode.galleryRightToLeft; List imageWidgets = images.map((imageKey) { ImageProvider imageProvider = _createImageProviderFromKey(imageKey, context); return Expanded( child: ComicImage( image: imageProvider, fit: BoxFit.contain, ), ); }).toList(); if (reverse) { imageWidgets = imageWidgets.reversed.toList(); } return axis == Axis.vertical ? Column(children: imageWidgets) : Row(children: imageWidgets); } @override Future animateToPage(int page) { if ((page - controller.page!.round()).abs() > 1) { controller.jumpToPage(page > controller.page! ? page - 1 : page + 1); } return controller.animateToPage( page, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } @override void toPage(int page) { controller.jumpToPage(page); } @override void handleDoubleTap(Offset location) { if (appdata.settings['quickCollectImage'] == 'DoubleTap') { context.readerScaffold.addImageFavorite(); return; } var controller = photoViewControllers[reader.page]!; controller.onDoubleClick?.call(); } @override void handleLongPressDown(Offset location) { if (!appdata.settings['enableLongPressToZoom']) { return; } var photoViewController = photoViewControllers[reader.page]!; double target = photoViewController.getInitialScale!.call()! * 1.75; var size = MediaQuery.of(context).size; photoViewController.animateScale?.call( target, Offset(size.width / 2 - location.dx, size.height / 2 - location.dy), ); } @override void handleLongPressUp(Offset location) { if (!appdata.settings['enableLongPressToZoom']) { return; } var photoViewController = photoViewControllers[reader.page]!; double target = photoViewController.getInitialScale!.call()!; photoViewController.animateScale?.call(target); } @override void handleKeyEvent(KeyEvent event) { bool? forward; if (reader.mode == ReaderMode.galleryLeftToRight && event.logicalKey == LogicalKeyboardKey.arrowRight) { forward = true; } else if (reader.mode == ReaderMode.galleryRightToLeft && event.logicalKey == LogicalKeyboardKey.arrowLeft) { forward = true; } else if (reader.mode == ReaderMode.galleryTopToBottom && event.logicalKey == LogicalKeyboardKey.arrowDown) { forward = true; } else if (reader.mode == ReaderMode.galleryTopToBottom && event.logicalKey == LogicalKeyboardKey.arrowUp) { forward = false; } else if (reader.mode == ReaderMode.galleryLeftToRight && event.logicalKey == LogicalKeyboardKey.arrowLeft) { forward = false; } else if (reader.mode == ReaderMode.galleryRightToLeft && event.logicalKey == LogicalKeyboardKey.arrowRight) { forward = false; } if (event is KeyDownEvent || event is KeyRepeatEvent) { if (forward == true) { controller.nextPage( duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } else if (forward == false) { controller.previousPage( duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } } } @override bool handleOnTap(Offset location) { return false; } } const Set _kTouchLikeDeviceTypes = { PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.stylus, PointerDeviceKind.invertedStylus, PointerDeviceKind.unknown }; const double _kChangeChapterOffset = 160; class _ContinuousMode extends StatefulWidget { const _ContinuousMode({super.key}); @override State<_ContinuousMode> createState() => _ContinuousModeState(); } class _ContinuousModeState extends State<_ContinuousMode> implements _ImageViewController { late _ReaderState reader; var itemScrollController = ItemScrollController(); var itemPositionsListener = ItemPositionsListener.create(); var photoViewController = PhotoViewController(); ScrollController? _scrollController; ScrollController get scrollController => _scrollController!; var isCTRLPressed = false; static var _isMouseScrolling = false; var fingers = 0; bool disableScroll = false; late List cached; int get preCacheCount => appdata.settings["preloadImageCount"]; /// Whether the user was scrolling the page. /// The gesture detector has a delay to detect tap event. /// To handle the tap event, we need to know if the user was scrolling before the delay. bool delayedIsScrolling = false; void delayedSetIsScrolling(bool value) { Future.delayed( const Duration(milliseconds: 300), () => delayedIsScrolling = value, ); } bool prepareToPrevChapter = false; bool prepareToNextChapter = false; bool jumpToNextChapter = false; bool jumpToPrevChapter = false; @override void initState() { reader = context.reader; reader._imageViewController = this; itemPositionsListener.itemPositions.addListener(onPositionChanged); cached = List.filled(reader.maxPage + 2, false); Future.delayed( const Duration(milliseconds: 100), () => cacheImages(reader.page), ); super.initState(); } @override void dispose() { itemPositionsListener.itemPositions.removeListener(onPositionChanged); super.dispose(); } void onPositionChanged() { if (itemPositionsListener.itemPositions.value.isEmpty) { return; } var page = itemPositionsListener.itemPositions.value.first.index; page = page.clamp(1, reader.maxPage); if (page != reader.page) { reader.setPage(page); context.readerScaffold.update(); } cacheImages(page); } double? futurePosition; void smoothTo(double offset) { futurePosition ??= scrollController.offset; if (futurePosition! > scrollController.position.maxScrollExtent && offset > 0) { return; } else if (futurePosition! < scrollController.position.minScrollExtent && offset < 0) { return; } futurePosition = futurePosition! + offset * 1.2; futurePosition = futurePosition!.clamp( scrollController.position.minScrollExtent, scrollController.position.maxScrollExtent, ); scrollController.animateTo( futurePosition!, duration: const Duration(milliseconds: 200), curve: Curves.linear, ); } void onPointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent) { if (!_isMouseScrolling) { setState(() { _isMouseScrolling = true; }); } if (isCTRLPressed) { return; } smoothTo(event.scrollDelta.dy); } } void cacheImages(int current) { for (int i = current + 1; i <= current + preCacheCount; i++) { if (i <= reader.maxPage && !cached[i]) { _precacheImage(i, context); cached[i] = true; } } } void onScroll() { if (prepareToPrevChapter) { jumpToNextChapter = false; jumpToPrevChapter = scrollController.offset < scrollController.position.minScrollExtent - _kChangeChapterOffset; } else if (prepareToNextChapter) { jumpToNextChapter = scrollController.offset > scrollController.position.maxScrollExtent + _kChangeChapterOffset; jumpToPrevChapter = false; } } @override Widget build(BuildContext context) { Widget widget = ScrollablePositionedList.builder( initialScrollIndex: reader.page, itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener, scrollControllerCallback: (scrollController) { if (_scrollController != null) { _scrollController!.removeListener(onScroll); } _scrollController = scrollController; _scrollController!.addListener(onScroll); }, itemCount: reader.maxPage + 2, addSemanticIndexes: false, scrollDirection: reader.mode == ReaderMode.continuousTopToBottom ? Axis.vertical : Axis.horizontal, reverse: reader.mode == ReaderMode.continuousRightToLeft, physics: isCTRLPressed || _isMouseScrolling || disableScroll ? const NeverScrollableScrollPhysics() : const BouncingScrollPhysics(), itemBuilder: (context, index) { if (index == 0 || index == reader.maxPage + 1) { return const SizedBox(); } double? width, height; if (reader.mode == ReaderMode.continuousLeftToRight || reader.mode == ReaderMode.continuousRightToLeft) { height = double.infinity; } else { width = double.infinity; } ImageProvider image = _createImageProvider(index, context); return ColoredBox( color: context.colorScheme.surface, child: ComicImage( filterQuality: FilterQuality.medium, image: image, width: width, height: height, fit: BoxFit.contain, ), ); }, scrollBehavior: const MaterialScrollBehavior() .copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes), ); widget = Stack( children: [ Positioned.fill(child: buildBackground(context)), Positioned.fill(child: widget), ], ); widget = Listener( onPointerDown: (event) { fingers++; if (fingers > 1 && !disableScroll) { setState(() { disableScroll = true; }); } futurePosition = null; if (_isMouseScrolling) { setState(() { _isMouseScrolling = false; }); } }, onPointerUp: (event) { fingers--; if (fingers <= 1 && disableScroll) { setState(() { disableScroll = false; }); } if (fingers == 0) { if (jumpToPrevChapter) { context.readerScaffold.setFloatingButton(0); reader.toPrevChapter(); } else if (jumpToNextChapter) { context.readerScaffold.setFloatingButton(0); reader.toNextChapter(); } } }, onPointerCancel: (event) { fingers--; if (fingers <= 1 && disableScroll) { setState(() { disableScroll = false; }); } }, onPointerPanZoomUpdate: (event) { if (event.scale == 1.0) { smoothTo(0 - event.panDelta.dy); } }, onPointerMove: (event) { Offset value = event.delta; if (photoViewController.scale == 1 || fingers != 1) { return; } if (scrollController.offset != scrollController.position.maxScrollExtent && scrollController.offset != scrollController.position.minScrollExtent) { if (reader.mode == ReaderMode.continuousTopToBottom) { value = Offset(value.dx, 0); } else { value = Offset(0, value.dy); } } photoViewController.updateMultiple( position: photoViewController.position + value); }, onPointerSignal: onPointerSignal, child: widget, ); widget = NotificationListener( onNotification: (notification) { if (notification is ScrollStartNotification) { delayedSetIsScrolling(true); } else if (notification is ScrollEndNotification) { delayedSetIsScrolling(false); } if (notification is ScrollUpdateNotification) { if (!scrollController.hasClients) return false; if (scrollController.position.pixels <= scrollController.position.minScrollExtent && !reader.isFirstChapterOfGroup) { if (!prepareToPrevChapter) { jumpToPrevChapter = false; jumpToNextChapter = false; context.readerScaffold.setFloatingButton(-1); setState(() { prepareToPrevChapter = true; }); } } else if (scrollController.position.pixels >= scrollController.position.maxScrollExtent && !reader.isLastChapterOfGroup) { if (!prepareToNextChapter) { jumpToPrevChapter = false; jumpToNextChapter = false; context.readerScaffold.setFloatingButton(1); setState(() { prepareToNextChapter = true; }); } } else { context.readerScaffold.setFloatingButton(0); if (prepareToPrevChapter || prepareToNextChapter) { jumpToPrevChapter = false; jumpToNextChapter = false; setState(() { prepareToPrevChapter = false; prepareToNextChapter = false; }); } } } return true; }, child: widget, ); var width = MediaQuery.of(context).size.width; var height = MediaQuery.of(context).size.height; if (appdata.settings['limitImageWidth'] && width / height > 0.7 && reader.mode == ReaderMode.continuousTopToBottom) { width = height * 0.7; } return PhotoView.customChild( backgroundDecoration: BoxDecoration( color: context.colorScheme.surface, ), childSize: Size(width, height), minScale: 1.0, maxScale: 2.5, strictScale: true, controller: photoViewController, child: SizedBox( width: width, height: height, child: widget, ), ); } Widget buildBackground(BuildContext context) { return Column( children: [ SizedBox(height: context.padding.top + 16), if (prepareToPrevChapter) _SwipeChangeChapterProgress( controller: scrollController, isPrev: true, ), const Spacer(), if (prepareToNextChapter) _SwipeChangeChapterProgress( controller: scrollController, isPrev: false, ), SizedBox(height: 36), ], ); } @override Future animateToPage(int page) { return itemScrollController.scrollTo( index: page, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } @override void handleDoubleTap(Offset location) { if (appdata.settings['quickCollectImage'] == 'DoubleTap') { context.readerScaffold.addImageFavorite(); return; } double target; if (photoViewController.scale != photoViewController.getInitialScale?.call()) { target = photoViewController.getInitialScale!.call()!; } else { target = photoViewController.getInitialScale!.call()! * 1.75; } var size = MediaQuery.of(context).size; photoViewController.animateScale?.call( target, Offset(size.width / 2 - location.dx, size.height / 2 - location.dy), ); } @override void handleLongPressDown(Offset location) { if (!appdata.settings['enableLongPressToZoom'] || delayedIsScrolling) { return; } double target = photoViewController.getInitialScale!.call()! * 1.75; var size = MediaQuery.of(context).size; photoViewController.animateScale?.call( target, Offset(size.width / 2 - location.dx, size.height / 2 - location.dy), ); } @override void handleLongPressUp(Offset location) { if (!appdata.settings['enableLongPressToZoom']) { return; } double target = photoViewController.getInitialScale!.call()!; photoViewController.animateScale?.call(target); } @override void toPage(int page) { itemScrollController.jumpTo(index: page); futurePosition = null; } @override void handleKeyEvent(KeyEvent event) { if (event.logicalKey == LogicalKeyboardKey.controlLeft || event.logicalKey == LogicalKeyboardKey.controlRight) { setState(() { if (event is KeyDownEvent) { isCTRLPressed = true; } else if (event is KeyUpEvent) { isCTRLPressed = false; } }); } if (event is KeyUpEvent) { return; } bool? forward; if (reader.mode == ReaderMode.continuousLeftToRight && event.logicalKey == LogicalKeyboardKey.arrowRight) { forward = true; } else if (reader.mode == ReaderMode.continuousRightToLeft && event.logicalKey == LogicalKeyboardKey.arrowLeft) { forward = true; } else if (reader.mode == ReaderMode.continuousTopToBottom && event.logicalKey == LogicalKeyboardKey.arrowDown) { forward = true; } else if (reader.mode == ReaderMode.continuousTopToBottom && event.logicalKey == LogicalKeyboardKey.arrowUp) { forward = false; } else if (reader.mode == ReaderMode.continuousLeftToRight && event.logicalKey == LogicalKeyboardKey.arrowLeft) { forward = false; } else if (reader.mode == ReaderMode.continuousRightToLeft && event.logicalKey == LogicalKeyboardKey.arrowRight) { forward = false; } if (forward == true) { scrollController.animateTo( scrollController.offset + context.height, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } else if (forward == false) { scrollController.animateTo( scrollController.offset - context.height, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); } } @override bool handleOnTap(Offset location) { if (delayedIsScrolling) { return true; } return false; } } ImageProvider _createImageProviderFromKey( String imageKey, BuildContext context) { var reader = context.reader; return ReaderImageProvider( imageKey, reader.type.comicSource?.key, reader.cid, reader.eid, reader.page, ); } ImageProvider _createImageProvider(int page, BuildContext context) { var reader = context.reader; var imageKey = reader.images![page - 1]; return _createImageProviderFromKey(imageKey, context); } void _precacheImage(int page, BuildContext context) { precacheImage( _createImageProvider(page, context), context, ); } class _SwipeChangeChapterProgress extends StatefulWidget { const _SwipeChangeChapterProgress({ this.controller, required this.isPrev, }); final ScrollController? controller; final bool isPrev; @override State<_SwipeChangeChapterProgress> createState() => _SwipeChangeChapterProgressState(); } class _SwipeChangeChapterProgressState extends State<_SwipeChangeChapterProgress> { double value = 0; late final isPrev = widget.isPrev; ScrollController? controller; @override void initState() { super.initState(); if (widget.controller != null) { controller = widget.controller; controller!.addListener(onScroll); } } @override void didUpdateWidget(covariant _SwipeChangeChapterProgress oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { controller?.removeListener(onScroll); controller = widget.controller; controller?.addListener(onScroll); if (value != 0) { setState(() { value = 0; }); } } } @override void dispose() { super.dispose(); controller?.removeListener(onScroll); } void onScroll() { var position = controller!.position.pixels; var offset = isPrev ? controller!.position.minScrollExtent - position : position - controller!.position.maxScrollExtent; var newValue = offset / _kChangeChapterOffset; newValue = newValue.clamp(0.0, 1.0); if (newValue != value) { setState(() { value = newValue; }); } } @override Widget build(BuildContext context) { final msg = widget.isPrev ? "Swipe down for previous chapter".tl : "Swipe up for next chapter".tl; return CustomPaint( painter: _ProgressPainter( value: value, backgroundColor: context.colorScheme.surfaceContainerLow, color: context.colorScheme.surfaceContainerHighest, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( widget.isPrev ? Icons.arrow_downward : Icons.arrow_upward, color: context.colorScheme.onSurface, size: 16, ), const SizedBox(width: 4), Text(msg), ], ).paddingVertical(6).paddingHorizontal(16), ); } } class _ProgressPainter extends CustomPainter { final double value; final Color backgroundColor; final Color color; const _ProgressPainter({ required this.value, required this.backgroundColor, required this.color, }); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = backgroundColor ..style = PaintingStyle.fill; canvas.drawRRect( RRect.fromLTRBR(0, 0, size.width, size.height, Radius.circular(16)), paint, ); paint.color = color; canvas.drawRRect( RRect.fromLTRBR( 0, 0, size.width * value, size.height, Radius.circular(16)), paint, ); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return oldDelegate is! _ProgressPainter || oldDelegate.value != value || oldDelegate.backgroundColor != backgroundColor || oldDelegate.color != color; } }