mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
implement continuous mode, gesture, keyboard recognition
This commit is contained in:
390
lib/pages/reader/comic_image.dart
Normal file
390
lib/pages/reader/comic_image.dart
Normal file
@@ -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<String, String>? 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<double>? opacity;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final bool isAntiAlias;
|
||||
|
||||
static void clear() => _ComicImageState.clear();
|
||||
|
||||
@override
|
||||
State<ComicImage> createState() => _ComicImageState();
|
||||
}
|
||||
|
||||
class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
|
||||
ImageStream? _imageStream;
|
||||
ImageInfo? _imageInfo;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
bool _isListeningToStream = false;
|
||||
late bool _invertColors;
|
||||
int? _frameNumber;
|
||||
bool _wasSynchronouslyLoaded = false;
|
||||
late DisposableBuildContext<State<ComicImage>> _scrollAwareContext;
|
||||
Object? _lastException;
|
||||
ImageStreamCompleterHandle? _completerHandle;
|
||||
|
||||
static final Map<int, Size> _cache = {};
|
||||
|
||||
static clear() => _cache.clear();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollAwareContext = DisposableBuildContext<State<ComicImage>>(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<Object>(
|
||||
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<ImageStream>('stream', _imageStream));
|
||||
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
|
||||
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
|
||||
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
|
||||
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
|
||||
}
|
||||
}
|
@@ -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,6 +68,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
}
|
||||
|
||||
void onMouseWheel(bool forward) {
|
||||
if (context.reader.mode.key.startsWith('gallery')) {
|
||||
if (forward) {
|
||||
if (!context.reader.toNextPage()) {
|
||||
context.reader.toNextChapter();
|
||||
@@ -55,10 +79,21 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: () {
|
||||
DesktopMenuEntry(
|
||||
text: "Settings".tl,
|
||||
onClick: () {
|
||||
context.readerScaffold.openSetting();
|
||||
}),
|
||||
DesktopMenuEntry(text: "Chapters".tl, onClick: () {
|
||||
DesktopMenuEntry(
|
||||
text: "Chapters".tl,
|
||||
onClick: () {
|
||||
context.readerScaffold.openChapterDrawer();
|
||||
}),
|
||||
DesktopMenuEntry(text: "Fullscreen".tl, onClick: () {
|
||||
DesktopMenuEntry(
|
||||
text: "Fullscreen".tl,
|
||||
onClick: () {
|
||||
context.reader.fullscreen();
|
||||
}),
|
||||
DesktopMenuEntry(text: "Exit".tl, onClick: () {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@@ -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<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
|
||||
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<void> 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) {
|
||||
|
@@ -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<Reader> 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<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
autoPageTurningTimer?.cancel();
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ReaderScaffold(
|
||||
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<void> animateToPage(int page);
|
||||
|
||||
void handleDoubleTap(Offset location);
|
||||
|
||||
void handleLongPressDown(Offset location);
|
||||
|
||||
void handleLongPressUp(Offset location);
|
||||
|
||||
void handleKeyEvent(KeyEvent event);
|
||||
}
|
||||
|
@@ -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:
|
||||
|
@@ -33,12 +33,12 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
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");
|
||||
|
@@ -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:
|
||||
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user