Files
venera/lib/pages/reader/images.dart
enximi ce0d10aeb2 Add a feature to allow saving custom reader settings for each comic. (#459)
* Add a feature to allow saving custom reader settings for each  comic.

* Comic-specific settings disabled by default
2025-08-10 16:02:44 +08:00

1282 lines
37 KiB
Dart

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();
}
@override
void dispose() {
super.dispose();
ImageDownloader.cancelAllLoadingImages();
}
void load() async {
if (inProgress) return;
inProgress = true;
if (reader.type == ComicType.local ||
(LocalManager().isDownloaded(
reader.cid,
reader.type,
reader.chapter,
reader.widget.chapters,
))) {
try {
var images = await LocalManager().getImages(
reader.cid,
reader.type,
reader.chapter,
);
setState(() {
reader.images = images;
reader.isLoading = false;
inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
});
} catch (e) {
setState(() {
error = e.toString();
reader.isLoading = false;
inProgress = false;
});
}
} else {
var cp = reader.widget.chapters?.ids.elementAtOrNull(reader.chapter - 1);
var res = await reader.type.comicSource!.loadComicPages!(
reader.widget.cid,
cp,
);
if (res.error) {
setState(() {
error = res.errorMessage;
reader.isLoading = false;
inProgress = false;
});
} else {
setState(() {
reader.images = res.data;
reader.isLoading = false;
inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
});
}
}
context.readerScaffold.update();
}
@override
Widget build(BuildContext context) {
if (reader.isLoading) {
load();
return const Center(child: CircularProgressIndicator());
} else if (error != null) {
return GestureDetector(
onTap: () {
context.readerScaffold.openOrClose();
},
child: SizedBox.expand(
child: 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;
int get preCacheCount => appdata.settings["preloadImageCount"];
var photoViewControllers = <int, PhotoViewController>{};
late _ReaderState reader;
/// [totalPages] is the total number of pages in the current chapter.
/// More than one images can be displayed on one page.
int get totalPages {
if (!reader.showSingleImageOnFirstPage(reader.cid, reader.type)) {
return (reader.images!.length /
reader.imagesPerPage(reader.cid, reader.type))
.ceil();
} else {
return 1 +
((reader.images!.length - 1) /
reader.imagesPerPage(reader.cid, reader.type))
.ceil();
}
}
var imageStates = <State<ComicImage>>{};
bool isLongPressing = false;
int fingers = 0;
@override
void initState() {
reader = context.reader;
controller = PageController(initialPage: reader.page);
reader._imageViewController = this;
Future.microtask(() {
context.readerScaffold.setFloatingButton(0);
});
super.initState();
}
/// Get the range of images for the given page. [page] is 1-based.
(int start, int end) getPageImagesRange(int page) {
var imagesPerPage = reader.imagesPerPage(reader.cid, reader.type);
if (reader.showSingleImageOnFirstPage(reader.cid, reader.type)) {
if (page == 1) {
return (0, 1);
} else {
int startIndex = (page - 2) * imagesPerPage + 1;
int endIndex = math.min(
startIndex + imagesPerPage,
reader.images!.length,
);
return (startIndex, endIndex);
}
} else {
int startIndex = (page - 1) * imagesPerPage;
int endIndex = math.min(
startIndex + imagesPerPage,
reader.images!.length,
);
return (startIndex, endIndex);
}
}
/// [cache] is used to cache the images.
/// The count of images to cache is determined by the [preCacheCount] setting.
/// For previous page and next page, it will do a memory cache.
/// For current page, it will do nothing because it is already on the screen.
/// For other pages, it will do a pre-download cache.
void cache(int startPage) {
for (int i = startPage - 1; i <= startPage + preCacheCount; i++) {
if (i == startPage || i <= 0 || i > totalPages) continue;
bool shouldPreCache = i == startPage + 1 || i == startPage - 1;
_cachePage(i, shouldPreCache);
}
}
void _cachePage(int page, bool shouldPreCache) {
var (startIndex, endIndex) = getPageImagesRange(page);
for (int i = startIndex; i < endIndex; i++) {
if (shouldPreCache) {
_precacheImage(i + 1, context);
} else {
_preDownloadImage(i + 1, context);
}
}
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (event) {
fingers++;
},
onPointerUp: (event) {
fingers--;
},
onPointerCancel: (event) {
fingers--;
},
onPointerMove: (event) {
if (isLongPressing) {
var controller = photoViewControllers[reader.page]!;
Offset value = event.delta;
if (isLongPressing) {
controller.updateMultiple(position: controller.position + value);
}
}
},
child: 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 {
var (startIndex, endIndex) = getPageImagesRange(index);
List<String> pageImages = reader.images!.sublist(
startIndex,
endIndex,
);
cache(index);
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage(reader.cid, reader.type) == 1 ||
pageImages.length == 1) {
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
controller: photoViewControllers[index],
imageProvider: _createImageProviderFromKey(
pageImages[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry);
},
);
}
return PhotoViewGalleryPageOptions.customChild(
childSize: reader.size * 2,
controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
child: buildPageImages(pageImages, startIndex),
);
}
},
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(reader.cid, reader.type, 1);
}
} else if (i == totalPages + 1) {
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
reader.toPage(reader.cid, reader.type, totalPages);
}
} else {
reader.setPage(i);
context.readerScaffold.update();
}
// Remove other pages' controllers to reset their state.
var keys = photoViewControllers.keys.toList();
for (var key in keys) {
if (key != i) {
photoViewControllers.remove(key);
}
}
},
),
);
}
Widget buildPageImages(List<String> images, int startIndex) {
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
? Axis.vertical
: Axis.horizontal;
bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
if (reverse) {
images = images.reversed.toList();
}
List<Widget> imageWidgets;
if (images.length == 2) {
imageWidgets = [
Expanded(
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(
images[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.bottomCenter
: Alignment.centerRight,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
),
Expanded(
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(
images[1],
context,
startIndex + 2,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.topCenter
: Alignment.centerLeft,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
),
];
} else {
imageWidgets = images.map((imageKey) {
startIndex++;
ImageProvider imageProvider = _createImageProviderFromKey(
imageKey,
context,
startIndex,
);
return Expanded(
child: ComicImage(
image: imageProvider,
fit: BoxFit.contain,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
);
}).toList();
}
return axis == Axis.vertical
? Column(children: imageWidgets)
: Row(children: imageWidgets);
}
@override
Future<void> 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'] || fingers != 1) {
return;
}
var photoViewController = photoViewControllers[reader.page]!;
double target = photoViewController.getInitialScale!.call()! * 1.75;
var size = reader.size;
Offset zoomPosition;
if (appdata.settings['longPressZoomPosition'] != 'center') {
zoomPosition = Offset(
size.width / 2 - location.dx,
size.height / 2 - location.dy,
);
} else {
zoomPosition = Offset(0, 0);
}
photoViewController.animateScale?.call(target, zoomPosition);
isLongPressing = true;
}
@override
void handleLongPressUp(Offset location) {
if (!appdata.settings['enableLongPressToZoom'] || !isLongPressing) {
return;
}
var photoViewController = photoViewControllers[reader.page]!;
double target = photoViewController.getInitialScale!.call()!;
photoViewController.animateScale?.call(target);
isLongPressing = false;
}
Timer? keyRepeatTimer;
@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) {
if (keyRepeatTimer != null) {
keyRepeatTimer!.cancel();
keyRepeatTimer = null;
}
if (forward == true) {
reader.toPage(reader.cid, reader.type, reader.page + 1);
} else if (forward == false) {
reader.toPage(reader.cid, reader.type, reader.page - 1);
}
}
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
keyRepeatTimer = Timer.periodic(
reader.enablePageAnimation(reader.cid, reader.type)
? const Duration(milliseconds: 200)
: const Duration(milliseconds: 50),
(timer) {
if (!mounted) {
timer.cancel();
return;
} else if (forward == true) {
reader.toPage(reader.cid, reader.type, reader.page + 1);
} else if (forward == false) {
reader.toPage(reader.cid, reader.type, reader.page - 1);
}
},
);
}
if (event is KeyUpEvent && keyRepeatTimer != null) {
keyRepeatTimer!.cancel();
keyRepeatTimer = null;
}
}
@override
bool handleOnTap(Offset location) {
return false;
}
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
))!.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
if (reader.imagesPerPage(reader.cid, reader.type) == 1) {
imageKey = reader.images![reader.page - 1];
} else {
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
}
return imageKey;
}
}
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
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<bool> 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;
var imageStates = <State<ComicImage>>{};
void delayedSetIsScrolling(bool value) {
Future.delayed(
const Duration(milliseconds: 300),
() => delayedIsScrolling = value,
);
}
bool prepareToPrevChapter = false;
bool prepareToNextChapter = false;
bool jumpToNextChapter = false;
bool jumpToPrevChapter = false;
bool isZoomedIn = false;
bool isLongPressing = 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]) {
_preDownloadImage(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;
}
}
bool onScaleUpdate([double? scale]) {
if (prepareToNextChapter || prepareToPrevChapter) {
setState(() {
prepareToPrevChapter = false;
prepareToNextChapter = false;
});
context.readerScaffold.setFloatingButton(0);
}
var isZoomedIn = (scale ?? photoViewController.scale) != 1.0;
if (isZoomedIn != this.isZoomedIn) {
setState(() {
this.isZoomedIn = isZoomedIn;
});
}
return 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()
: isZoomedIn
? const ClampingScrollPhysics()
: 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,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
);
},
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;
}
Offset offset;
var sp = scrollController.position;
if (sp.pixels <= sp.minScrollExtent ||
sp.pixels >= sp.maxScrollExtent) {
offset = Offset(value.dx, value.dy);
} else {
if (reader.mode == ReaderMode.continuousTopToBottom) {
offset = Offset(value.dx, 0);
} else {
offset = Offset(0, value.dy);
}
}
if (isLongPressing) {
offset += value;
}
photoViewController.updateMultiple(
position: photoViewController.position + offset,
);
},
onPointerSignal: onPointerSignal,
child: widget,
);
widget = NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
delayedSetIsScrolling(true);
} else if (notification is ScrollEndNotification) {
delayedSetIsScrolling(false);
}
var scale = photoViewController.scale ?? 1.0;
if (notification is ScrollUpdateNotification &&
(scale - 1).abs() < 0.05) {
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 = reader.size.width;
var height = reader.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,
onScaleUpdate: onScaleUpdate,
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<void> 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),
);
onScaleUpdate(target);
}
@override
void handleLongPressDown(Offset location) {
if (!appdata.settings['enableLongPressToZoom'] || delayedIsScrolling) {
return;
}
double target = photoViewController.getInitialScale!.call()! * 1.75;
var size = reader.size;
Offset zoomPosition;
if (appdata.settings['longPressZoomPosition'] != 'center') {
zoomPosition = Offset(
size.width / 2 - location.dx,
size.height / 2 - location.dy,
);
} else {
zoomPosition = Offset(0, 0);
}
photoViewController.animateScale?.call(target, zoomPosition);
onScaleUpdate(target);
isLongPressing = true;
}
@override
void handleLongPressUp(Offset location) {
if (!appdata.settings['enableLongPressToZoom']) {
return;
}
double target = photoViewController.getInitialScale!.call()!;
photoViewController.animateScale?.call(target);
onScaleUpdate(target);
isLongPressing = false;
}
@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 * 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} else if (forward == false) {
scrollController.animateTo(
scrollController.offset - context.height * 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}
}
@override
bool handleOnTap(Offset location) {
if (delayedIsScrolling) {
return true;
}
return false;
}
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
))!.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
return imageKey;
}
}
ImageProvider _createImageProviderFromKey(
String imageKey,
BuildContext context,
int page,
) {
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, page);
}
/// [_precacheImage] is used to precache the image for the given page.
/// The image is cached using the flutter's [precacheImage] method.
/// The image will be downloaded and decoded into memory.
void _precacheImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
precacheImage(_createImageProvider(page, context), context);
}
/// [_preDownloadImage] is used to download the image for the given page.
/// The image is downloaded using the [CacheManager] and saved to the local storage.
void _preDownloadImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
var reader = context.reader;
var imageKey = reader.images![page - 1];
if (imageKey.startsWith("file://")) {
return;
}
var cid = reader.cid;
var eid = reader.eid;
var sourceKey = reader.type.comicSource?.key;
ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid);
}
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;
}
}