diff --git a/lib/pages/reader/comic_image.dart b/lib/pages/reader/comic_image.dart new file mode 100644 index 0000000..841058c --- /dev/null +++ b/lib/pages/reader/comic_image.dart @@ -0,0 +1,390 @@ +part of 'reader.dart'; + +class ComicImage extends StatefulWidget { + /// Modified from flutter Image + ComicImage({ + required ImageProvider image, + super.key, + double scale = 1.0, + this.semanticLabel, + this.excludeFromSemantics = false, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.filterQuality = FilterQuality.medium, + this.isAntiAlias = false, + Map? headers, + int? cacheWidth, + int? cacheHeight, + } + ): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), + assert(cacheWidth == null || cacheWidth > 0), + assert(cacheHeight == null || cacheHeight > 0); + + final ImageProvider image; + + final String? semanticLabel; + + final bool excludeFromSemantics; + + final double? width; + + final double? height; + + final bool gaplessPlayback; + + final bool matchTextDirection; + + final Rect? centerSlice; + + final ImageRepeat repeat; + + final AlignmentGeometry alignment; + + final BoxFit? fit; + + final BlendMode? colorBlendMode; + + final FilterQuality filterQuality; + + final Animation? opacity; + + final Color? color; + + final bool isAntiAlias; + + static void clear() => _ComicImageState.clear(); + + @override + State createState() => _ComicImageState(); +} + +class _ComicImageState extends State with WidgetsBindingObserver { + ImageStream? _imageStream; + ImageInfo? _imageInfo; + ImageChunkEvent? _loadingProgress; + bool _isListeningToStream = false; + late bool _invertColors; + int? _frameNumber; + bool _wasSynchronouslyLoaded = false; + late DisposableBuildContext> _scrollAwareContext; + Object? _lastException; + ImageStreamCompleterHandle? _completerHandle; + + static final Map _cache = {}; + + static clear() => _cache.clear(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _scrollAwareContext = DisposableBuildContext>(this); + } + + @override + void dispose() { + assert(_imageStream != null); + WidgetsBinding.instance.removeObserver(this); + _stopListeningToStream(); + _completerHandle?.dispose(); + _scrollAwareContext.dispose(); + _replaceImage(info: null); + super.dispose(); + } + + @override + void didChangeDependencies() { + _updateInvertColors(); + _resolveImage(); + + if (TickerMode.of(context)) { + _listenToStream(); + } else { + _stopListeningToStream(keepStreamAlive: true); + } + + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(ComicImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.image != oldWidget.image) { + _resolveImage(); + } + } + + @override + void didChangeAccessibilityFeatures() { + super.didChangeAccessibilityFeatures(); + setState(() { + _updateInvertColors(); + }); + } + + @override + void reassemble() { + _resolveImage(); // in case the image cache was flushed + super.reassemble(); + } + + void _updateInvertColors() { + _invertColors = MediaQuery.maybeInvertColorsOf(context) + ?? SemanticsBinding.instance.accessibilityFeatures.invertColors; + } + + void _resolveImage() { + final ScrollAwareImageProvider provider = ScrollAwareImageProvider( + context: _scrollAwareContext, + imageProvider: widget.image, + ); + final ImageStream newStream = + provider.resolve(createLocalImageConfiguration( + context, + size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null, + )); + _updateSourceStream(newStream); + } + + ImageStreamListener? _imageStreamListener; + ImageStreamListener _getListener({bool recreateListener = false}) { + if(_imageStreamListener == null || recreateListener) { + _lastException = null; + _imageStreamListener = ImageStreamListener( + _handleImageFrame, + onChunk: _handleImageChunk, + onError: (Object error, StackTrace? stackTrace) { + setState(() { + _lastException = error; + }); + }, + ); + } + return _imageStreamListener!; + } + + void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) { + setState(() { + _replaceImage(info: imageInfo); + _loadingProgress = null; + _lastException = null; + _frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1; + _wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall; + }); + } + + void _handleImageChunk(ImageChunkEvent event) { + setState(() { + _loadingProgress = event; + _lastException = null; + }); + } + + void _replaceImage({required ImageInfo? info}) { + final ImageInfo? oldImageInfo = _imageInfo; + SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose()); + _imageInfo = info; + } + + // Updates _imageStream to newStream, and moves the stream listener + // registration from the old stream to the new stream (if a listener was + // registered). + void _updateSourceStream(ImageStream newStream) { + if (_imageStream?.key == newStream.key) { + return; + } + + if (_isListeningToStream) { + _imageStream!.removeListener(_getListener()); + } + + if (!widget.gaplessPlayback) { + setState(() { _replaceImage(info: null); }); + } + + setState(() { + _loadingProgress = null; + _frameNumber = null; + _wasSynchronouslyLoaded = false; + }); + + _imageStream = newStream; + if (_isListeningToStream) { + _imageStream!.addListener(_getListener()); + } + } + + void _listenToStream() { + if (_isListeningToStream) { + return; + } + + _imageStream!.addListener(_getListener()); + _completerHandle?.dispose(); + _completerHandle = null; + + _isListeningToStream = true; + } + + /// Stops listening to the image stream, if this state object has attached a + /// listener. + /// + /// If the listener from this state is the last listener on the stream, the + /// stream will be disposed. To keep the stream alive, set `keepStreamAlive` + /// to true, which create [ImageStreamCompleterHandle] to keep the completer + /// alive and is compatible with the [TickerMode] being off. + void _stopListeningToStream({bool keepStreamAlive = false}) { + if (!_isListeningToStream) { + return; + } + + if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) { + _completerHandle = _imageStream!.completer!.keepAlive(); + } + + _imageStream!.removeListener(_getListener()); + _isListeningToStream = false; + } + + @override + Widget build(BuildContext context) { + if (_lastException != null) { + // display error and retry button on screen + return SizedBox( + height: 300, + child: Center( + child: SizedBox( + height: 300, + child: Column( + children: [ + Expanded( + child: Center( + child: Text(_lastException.toString(), maxLines: 3,), + ), + ), + const SizedBox(height: 4,), + MouseRegion( + cursor: SystemMouseCursors.click, + child: Listener( + onPointerDown: (details){ + _resolveImage(); + }, + child: const SizedBox( + width: 84, + height: 36, + child: Center( + child: Text("Retry", style: TextStyle(color: Colors.blue),), + ), + ), + ), + ), + const SizedBox(height: 16,), + ], + ), + ), + ), + ); + } + + var width = widget.width??MediaQuery.of(context).size.width; + double? height; + + Size? cacheSize = _cache[widget.image.hashCode]; + if(cacheSize != null){ + height = cacheSize.height * (width / cacheSize.width); + height = height.ceilToDouble(); + } + + var brightness = Theme.of(context).brightness; + + if(_imageInfo != null){ + // Record the height and the width of the image + _cache[widget.image.hashCode] = Size( + _imageInfo!.image.width.toDouble(), + _imageInfo!.image.height.toDouble() + ); + // build image + Widget result = RawImage( + // Do not clone the image, because RawImage is a stateless wrapper. + // The image will be disposed by this state object when it is not needed + // anymore, such as when it is unmounted or when the image stream pushes + // a new image. + image: _imageInfo?.image, + debugImageLabel: _imageInfo?.debugLabel, + width: width, + height: height, + scale: _imageInfo?.scale ?? 1.0, + color: widget.color, + opacity: widget.opacity, + colorBlendMode: widget.colorBlendMode, + fit: widget.fit, + alignment: widget.alignment, + repeat: widget.repeat, + centerSlice: widget.centerSlice, + matchTextDirection: widget.matchTextDirection, + invertColors: _invertColors, + isAntiAlias: widget.isAntiAlias, + filterQuality: widget.filterQuality, + ); + + if (!widget.excludeFromSemantics) { + result = Semantics( + container: widget.semanticLabel != null, + image: true, + label: widget.semanticLabel ?? '', + child: result, + ); + } + result = SizedBox( + width: width, + height: height, + child: Center( + child: result, + ), + ); + return result; + } else { + // build progress + return SizedBox( + width: width, + height: height??300, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + backgroundColor: brightness == Brightness.dark + ? Colors.white24 + : Colors.black12, + strokeWidth: 3, + value: (_loadingProgress != null && + _loadingProgress!.expectedTotalBytes!=null && + _loadingProgress!.expectedTotalBytes! != 0) + ?_loadingProgress!.cumulativeBytesLoaded / _loadingProgress!.expectedTotalBytes! + :0, + ), + ), + ), + ); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add(DiagnosticsProperty('stream', _imageStream)); + description.add(DiagnosticsProperty('pixels', _imageInfo)); + description.add(DiagnosticsProperty('loadingProgress', _loadingProgress)); + description.add(DiagnosticsProperty('frameNumber', _frameNumber)); + description.add(DiagnosticsProperty('wasSynchronouslyLoaded', _wasSynchronouslyLoaded)); + } +} diff --git a/lib/pages/reader/gesture.dart b/lib/pages/reader/gesture.dart index c3bb3c1..7bb6acf 100644 --- a/lib/pages/reader/gesture.dart +++ b/lib/pages/reader/gesture.dart @@ -14,6 +14,8 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { static const _kDoubleTapMinTime = Duration(milliseconds: 200); + static const _kLongPressMinTime = Duration(milliseconds: 200); + static const _kDoubleTapMaxDistanceSquared = 20.0 * 20.0; static const _kTapToTurnPagePercent = 0.3; @@ -33,7 +35,28 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { return Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { + _lastTapPointer = event.pointer; + _lastTapMoveDistance = Offset.zero; _tapGestureRecognizer.addPointer(event); + Future.delayed(_kLongPressMinTime, () { + if (_lastTapPointer == event.pointer && + _lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) { + onLongPressedDown(event.position); + _longPressInProgress = true; + } + }); + }, + onPointerMove: (event) { + if (event.pointer == _lastTapPointer) { + _lastTapMoveDistance = event.delta + _lastTapMoveDistance!; + } + }, + onPointerUp: (event) { + if (_longPressInProgress) { + onLongPressedUp(event.position); + } + _lastTapPointer = null; + _lastTapMoveDistance = null; }, onPointerSignal: (event) { if (event is PointerScrollEvent) { @@ -45,20 +68,32 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { } void onMouseWheel(bool forward) { - if (forward) { - if (!context.reader.toNextPage()) { - context.reader.toNextChapter(); - } - } else { - if (!context.reader.toPrevPage()) { - context.reader.toPrevChapter(); + if (context.reader.mode.key.startsWith('gallery')) { + if (forward) { + if (!context.reader.toNextPage()) { + context.reader.toNextChapter(); + } + } else { + if (!context.reader.toPrevPage()) { + context.reader.toPrevChapter(); + } } } } TapUpDetails? _previousEvent; + int? _lastTapPointer; + + Offset? _lastTapMoveDistance; + + bool _longPressInProgress = false; + void onTapUp(TapUpDetails event) { + if (_longPressInProgress) { + _longPressInProgress = false; + return; + } final location = event.globalPosition; final previousLocation = _previousEvent?.globalPosition; if (previousLocation != null) { @@ -147,19 +182,35 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { context, location, [ - DesktopMenuEntry(text: "Settings".tl, onClick: () { - context.readerScaffold.openSetting(); - }), - DesktopMenuEntry(text: "Chapters".tl, onClick: () { - context.readerScaffold.openChapterDrawer(); - }), - DesktopMenuEntry(text: "Fullscreen".tl, onClick: () { - context.reader.fullscreen(); - }), - DesktopMenuEntry(text: "Exit".tl, onClick: () { - context.pop(); - }), + DesktopMenuEntry( + text: "Settings".tl, + onClick: () { + context.readerScaffold.openSetting(); + }), + DesktopMenuEntry( + text: "Chapters".tl, + onClick: () { + context.readerScaffold.openChapterDrawer(); + }), + DesktopMenuEntry( + text: "Fullscreen".tl, + onClick: () { + context.reader.fullscreen(); + }), + DesktopMenuEntry( + text: "Exit".tl, + onClick: () { + context.pop(); + }), ], ); } + + void onLongPressedUp(Offset location) { + context.reader._imageViewController?.handleLongPressUp(location); + } + + void onLongPressedDown(Offset location) { + context.reader._imageViewController?.handleLongPressDown(location); + } } diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index 69d70d3..17ee9b9 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -85,8 +85,7 @@ class _ReaderImagesState extends State<_ReaderImages> { if (reader.mode.isGallery) { return _GalleryMode(key: Key(reader.mode.key)); } else { - // TODO: Implement other modes - throw UnimplementedError(); + return _ContinuousMode(key: Key(reader.mode.key)); } } } @@ -218,6 +217,334 @@ class _GalleryModeState extends State<_GalleryMode> var controller = photoViewControllers[reader.page]!; controller.onDoubleClick?.call(); } + + @override + void handleLongPressDown(Offset location) { + 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) { + 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, + ); + } + } + } +} + +const Set _kTouchLikeDeviceTypes = { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + PointerDeviceKind.unknown +}; + +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(); + late ScrollController scrollController; + + var isCTRLPressed = false; + static var _isMouseScrolling = false; + var fingers = 0; + + @override + void initState() { + reader = context.reader; + reader._imageViewController = this; + itemPositionsListener.itemPositions.addListener(onPositionChanged); + super.initState(); + } + + void onPositionChanged() { + var page = itemPositionsListener.itemPositions.value.first.index; + page = page.clamp(1, reader.maxPage); + if (page != reader.page) { + reader.setPage(page); + context.readerScaffold.update(); + } + } + + double? futurePosition; + + void smoothTo(double offset) { + futurePosition ??= scrollController.offset; + 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); + } + } + + @override + Widget build(BuildContext context) { + Widget widget = ScrollablePositionedList.builder( + initialScrollIndex: reader.page, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + scrollControllerCallback: (scrollController) { + this.scrollController = scrollController; + }, + itemCount: reader.maxPage + 2, + addSemanticIndexes: false, + scrollDirection: reader.mode == ReaderMode.continuousTopToBottom + ? Axis.vertical + : Axis.horizontal, + reverse: reader.mode == ReaderMode.continuousRightToLeft, + physics: isCTRLPressed || _isMouseScrolling + ? const NeverScrollableScrollPhysics() + : const ClampingScrollPhysics(), + itemBuilder: (context, index) { + if (index == 0 || index == reader.maxPage + 1) { + return const SizedBox(); + } + double width = MediaQuery.of(context).size.width; + double height = MediaQuery.of(context).size.height; + + double imageWidth = width; + + if (height / width < 1.2) { + imageWidth = height / 1.2; + } + + _precacheImage(index, context); + + ImageProvider image = _createImageProvider(index, context); + + return ComicImage( + filterQuality: FilterQuality.medium, + image: image, + width: imageWidth, + height: imageWidth * 1.2, + fit: BoxFit.cover, + ); + }, + scrollBehavior: const MaterialScrollBehavior() + .copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes), + ); + + widget = Listener( + onPointerDown: (event) { + fingers++; + futurePosition = null; + if (_isMouseScrolling) { + setState(() { + _isMouseScrolling = false; + }); + } + }, + onPointerUp: (event) { + fingers--; + }, + 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, + ); + + return PhotoView.customChild( + backgroundDecoration: BoxDecoration( + color: context.colorScheme.surface, + ), + minScale: 1.0, + maxScale: 2.5, + strictScale: true, + controller: photoViewController, + child: SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: widget, + ), + ); + } + + @override + Future animateToPage(int page) { + return itemScrollController.scrollTo( + index: page, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } + + @override + void handleDoubleTap(Offset location) { + 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) { + 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) { + 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; + } + }); + } + 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, + ); + } + } } ImageProvider _createImageProvider(int page, BuildContext context) { diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart index 092eb71..9526b93 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -4,9 +4,12 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.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:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; @@ -22,10 +25,9 @@ import 'package:venera/utils/translations.dart'; import 'package:window_manager/window_manager.dart'; part 'scaffold.dart'; - part 'images.dart'; - part 'gesture.dart'; +part 'comic_image.dart'; extension _ReaderContext on BuildContext { _ReaderState get reader => findAncestorStateOfType<_ReaderState>()!; @@ -92,6 +94,8 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { @override bool isLoading = false; + var focusNode = FocusNode(); + @override void initState() { page = widget.initialPage ?? 1; @@ -101,15 +105,31 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { super.initState(); } + @override + void dispose() { + autoPageTurningTimer?.cancel(); + focusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return _ReaderScaffold( - child: _ReaderGestureDetector( - child: _ReaderImages(key: Key(chapter.toString())), + return KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: onKeyEvent, + child: _ReaderScaffold( + child: _ReaderGestureDetector( + child: _ReaderImages(key: Key(chapter.toString())), + ), ), ); } + void onKeyEvent(KeyEvent event) { + _imageViewController?.handleKeyEvent(event); + } + @override int get maxChapter => widget.chapters?.length ?? 1; @@ -279,4 +299,10 @@ abstract interface class _ImageViewController { Future animateToPage(int page); void handleDoubleTap(Offset location); + + void handleLongPressDown(Offset location); + + void handleLongPressUp(Offset location); + + void handleKeyEvent(KeyEvent event); } diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 378435b..f911b43 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -18,6 +18,23 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { bool get isOpen => _isOpen; + @override + void initState() { + sliderFocus.canRequestFocus = false; + sliderFocus.addListener(() { + if (sliderFocus.hasFocus) { + sliderFocus.nextFocus(); + } + }); + super.initState(); + } + + @override + void dispose() { + sliderFocus.dispose(); + super.dispose(); + } + void openOrClose() { setState(() { _isOpen = !_isOpen; @@ -248,8 +265,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ); } + var sliderFocus = FocusNode(); + Widget buildSlider() { return Slider( + focusNode: sliderFocus, value: context.reader.page.toDouble(), min: 1, max: diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index d137a69..5185d4c 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -33,12 +33,12 @@ class _ReaderSettingsState extends State { title: "Reading mode".tl, settingKey: "readerMode", optionTranslation: { - "galleryLeftToRight": "Gallery Left to Right".tl, - "galleryRightToLeft": "Gallery Right to Left".tl, - "galleryTopToBottom": "Gallery Top to Bottom".tl, - "continuousLeftToRight": "Continuous Left to Right".tl, - "continuousRightToLeft": "Continuous Right to Left".tl, - "continuousTopToBottom": "Continuous Top to Bottom".tl, + "galleryLeftToRight": "Gallery (Left to Right)".tl, + "galleryRightToLeft": "Gallery (Right to Left)".tl, + "galleryTopToBottom": "Gallery (Top to Bottom)".tl, + "continuousLeftToRight": "Continuous (Left to Right)".tl, + "continuousRightToLeft": "Continuous (Right to Left)".tl, + "continuousTopToBottom": "Continuous (Top to Bottom)".tl, }, onChanged: () { widget.onChanged?.call("readerMode"); diff --git a/pubspec.lock b/pubspec.lock index 3538693..6a98f95 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -367,6 +367,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.9" + scrollable_positioned_list: + dependency: "direct main" + description: + path: "packages/scrollable_positioned_list" + ref: "09e756b1f1b04e6298318d99ec20a787fb360f59" + resolved-ref: "09e756b1f1b04e6298318d99ec20a787fb360f59" + url: "https://github.com/venera-app/flutter.widgets" + source: git + version: "0.3.8+1" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index fea04f3..5c7fae0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,11 @@ dependencies: ref: 94724a0b mime: ^1.0.5 share_plus: ^10.0.2 + scrollable_positioned_list: + git: + url: https://github.com/venera-app/flutter.widgets + ref: 09e756b1f1b04e6298318d99ec20a787fb360f59 + path: packages/scrollable_positioned_list dev_dependencies: flutter_test: