From 601a7cd796c046ecf1c3bdfe47740c5719cdc1ef Mon Sep 17 00:00:00 2001 From: nyne Date: Mon, 7 Oct 2024 22:33:07 +0800 Subject: [PATCH] comic reading --- lib/components/components.dart | 3 +- lib/components/custom_slider.dart | 222 ------------- lib/components/effects.dart | 27 ++ lib/foundation/app.dart | 2 +- lib/foundation/appdata.dart | 4 + lib/foundation/history.dart | 64 ++-- .../image_provider/cached_image.dart | 1 - .../image_provider/reader_image.dart | 81 +++++ lib/pages/comic_page.dart | 22 +- lib/pages/home_page.dart | 12 +- lib/pages/reader/gesture.dart | 165 ++++++++++ lib/pages/reader/images.dart | 212 ++++++++++++ lib/pages/reader/reader.dart | 275 ++++++++++++++++ lib/pages/reader/scaffold.dart | 302 ++++++++++++++++++ pubspec.lock | 9 + pubspec.yaml | 4 + 16 files changed, 1127 insertions(+), 278 deletions(-) delete mode 100644 lib/components/custom_slider.dart create mode 100644 lib/components/effects.dart create mode 100644 lib/foundation/image_provider/reader_image.dart create mode 100644 lib/pages/reader/gesture.dart create mode 100644 lib/pages/reader/images.dart create mode 100644 lib/pages/reader/reader.dart create mode 100644 lib/pages/reader/scaffold.dart diff --git a/lib/components/components.dart b/lib/components/components.dart index 7ec33c2..e292491 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -40,4 +40,5 @@ part 'pop_up_widget.dart'; part 'scroll.dart'; part 'select.dart'; part 'side_bar.dart'; -part 'comic.dart'; \ No newline at end of file +part 'comic.dart'; +part 'effects.dart'; \ No newline at end of file diff --git a/lib/components/custom_slider.dart b/lib/components/custom_slider.dart deleted file mode 100644 index 92ba5db..0000000 --- a/lib/components/custom_slider.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:flutter/material.dart'; - -/// copied from flutter source -class _SliderDefaultsM3 extends SliderThemeData { - _SliderDefaultsM3(this.context) - : super(trackHeight: 4.0); - - final BuildContext context; - late final ColorScheme _colors = Theme.of(context).colorScheme; - - @override - Color? get activeTrackColor => _colors.primary; - - @override - Color? get inactiveTrackColor => _colors.surfaceContainerHighest; - - @override - Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); - - @override - Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38); - - @override - Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); - - @override - Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12); - - @override - Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38); - - @override - Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38); - - @override - Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38); - - @override - Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38); - - @override - Color? get thumbColor => _colors.primary; - - @override - Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface); - - @override - Color? get overlayColor => WidgetStateColor.resolveWith((Set states) { - if (states.contains(WidgetState.hovered)) { - return _colors.primary.withOpacity(0.08); - } - if (states.contains(WidgetState.focused)) { - return _colors.primary.withOpacity(0.12); - } - if (states.contains(WidgetState.dragged)) { - return _colors.primary.withOpacity(0.12); - } - - return Colors.transparent; - }); - - @override - TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith( - color: _colors.onPrimary, - ); - - @override - SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape(); -} - -class CustomSlider extends StatefulWidget { - const CustomSlider({required this.min, required this.max, required this.value, required this.divisions, required this.onChanged, this.reversed = false, super.key}); - - final double min; - - final double max; - - final double value; - - final int divisions; - - final void Function(double) onChanged; - - final bool reversed; - - @override - State createState() => _CustomSliderState(); -} - -class _CustomSliderState extends State { - late double value; - - @override - void initState() { - super.initState(); - value = widget.value; - } - - @override - void didUpdateWidget(CustomSlider oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.value != oldWidget.value) { - setState(() { - value = widget.value; - }); - } - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final theme = _SliderDefaultsM3(context); - return Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 12), - child: LayoutBuilder( - builder: (context, constrains) => MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (details){ - var dx = details.localPosition.dx; - if(widget.reversed){ - dx = constrains.maxWidth - dx; - } - var gap = constrains.maxWidth / widget.divisions; - var gapValue = (widget.max - widget.min) / widget.divisions; - widget.onChanged.call((dx / gap).round() * gapValue + widget.min); - }, - onVerticalDragUpdate: (details){ - var dx = details.localPosition.dx; - if(dx > constrains.maxWidth || dx < 0) return; - if(widget.reversed){ - dx = constrains.maxWidth - dx; - } - var gap = constrains.maxWidth / widget.divisions; - var gapValue = (widget.max - widget.min) / widget.divisions; - widget.onChanged.call((dx / gap).round() * gapValue + widget.min); - }, - child: SizedBox( - height: 24, - child: Center( - child: SizedBox( - height: 24, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: Center( - child: Container( - width: double.infinity, - height: 6, - decoration: BoxDecoration( - color: theme.inactiveTrackColor, - borderRadius: const BorderRadius.all(Radius.circular(10)) - ), - ), - ), - ), - if(constrains.maxWidth / widget.divisions > 10) - Positioned.fill( - child: Row( - children: (){ - var res = []; - for(int i = 0; i readEpisode; int? maxPage; - History(this.type, this.time, this.title, this.subtitle, this.cover, this.ep, - this.page, this.id, - [this.readEpisode = const {}, this.maxPage]); - History.fromModel( {required HistoryMixin model, required this.ep, required this.page, - this.readEpisode = const {}, + Set? readChapters, DateTime? time}) : type = model.historyType, title = model.title, subtitle = model.subTitle ?? '', cover = model.cover, id = model.id, + readEpisode = readChapters ?? {}, time = time ?? DateTime.now(); Map toMap() => { @@ -168,50 +168,22 @@ class HistoryManager with ChangeNotifier { /// /// This function would be called when user start reading. Future addHistory(History newItem) async { - var res = _db.select(""" - select * from history - where id == ? and type == ?; - """, [newItem.id, newItem.type.value]); - if (res.isEmpty) { - _db.execute(""" - insert into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page) + _db.execute(""" + insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """, [ - newItem.id, - newItem.title, - newItem.subtitle, - newItem.cover, - newItem.time.millisecondsSinceEpoch, - newItem.type.value, - newItem.ep, - newItem.page, - newItem.readEpisode.join(','), - newItem.maxPage - ]); - } else { - _db.execute(""" - update history - set time = ${DateTime.now().millisecondsSinceEpoch} - where id == ? and type == ?; - """, [newItem.id, newItem.type.value]); - } - updateCache(); - notifyListeners(); - } - - Future saveReadHistory(History history) async { - _db.execute(""" - update history - set time = ${DateTime.now().millisecondsSinceEpoch}, ep = ?, page = ?, readEpisode = ?, max_page = ? - where id == ? and type == ?; - """, [ - history.ep, - history.page, - history.readEpisode.join(','), - history.maxPage, - history.id, - history.type.value + newItem.id, + newItem.title, + newItem.subtitle, + newItem.cover, + newItem.time.millisecondsSinceEpoch, + newItem.type.value, + newItem.ep, + newItem.page, + newItem.readEpisode.join(','), + newItem.maxPage ]); + updateCache(); notifyListeners(); } diff --git a/lib/foundation/image_provider/cached_image.dart b/lib/foundation/image_provider/cached_image.dart index e5c7514..f4730c3 100644 --- a/lib/foundation/image_provider/cached_image.dart +++ b/lib/foundation/image_provider/cached_image.dart @@ -1,5 +1,4 @@ import 'dart:async' show Future, StreamController; -import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/foundation/cache_manager.dart'; diff --git a/lib/foundation/image_provider/reader_image.dart b/lib/foundation/image_provider/reader_image.dart new file mode 100644 index 0000000..84c72f0 --- /dev/null +++ b/lib/foundation/image_provider/reader_image.dart @@ -0,0 +1,81 @@ +import 'dart:async' show Future, StreamController; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:venera/foundation/cache_manager.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; +import 'package:venera/foundation/consts.dart'; +import 'package:venera/network/app_dio.dart'; +import 'base_image_provider.dart'; +import 'reader_image.dart' as image_provider; + +class ReaderImageProvider + extends BaseImageProvider { + /// Image provider for normal image. + const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid); + + final String imageKey; + + final String? sourceKey; + + final String cid; + + final String eid; + + @override + Future load(StreamController chunkEvents) async { + final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; + final cache = await CacheManager().findCache(cacheKey); + + if (cache != null) { + return await cache.readAsBytes(); + } + + var configs = {}; + if (sourceKey != null) { + var comicSource = ComicSource.find(sourceKey!); + configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {}; + } + configs['headers'] ??= { + 'user-agent': webUA, + }; + + var dio = AppDio(BaseOptions( + headers: configs['headers'], + method: configs['method'] ?? 'GET', + responseType: ResponseType.stream, + )); + + var req = await dio.request(configs['url'] ?? imageKey, + data: configs['data']); + var stream = req.data?.stream ?? (throw "Error: Empty response body."); + int? expectedBytes = req.data!.contentLength; + if (expectedBytes == -1) { + expectedBytes = null; + } + var buffer = []; + await for (var data in stream) { + buffer.addAll(data); + if (expectedBytes != null) { + chunkEvents.add(ImageChunkEvent( + cumulativeBytesLoaded: buffer.length, + expectedTotalBytes: expectedBytes, + )); + } + } + + if(configs['onResponse'] != null) { + buffer = configs['onResponse'](buffer); + } + + await CacheManager().writeCache(cacheKey, buffer); + return Uint8List.fromList(buffer); + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + String get key => "$imageKey@$sourceKey@$cid@$eid"; +} diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 28966f8..a994d60 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -9,6 +9,7 @@ import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/pages/favorites/favorite_actions.dart'; +import 'package:venera/pages/reader/reader.dart'; import 'package:venera/utils/translations.dart'; import 'dart:math' as math; @@ -458,9 +459,25 @@ abstract mixin class _ComicPageActions { /// [ep] the episode number, start from 1 /// /// [page] the page number, start from 1 - void read([int? ep, int? page]) {} + void read([int? ep, int? page]) { + App.rootContext.to( + () => Reader( + source: comicSource, + cid: comic.id, + name: comic.title, + chapters: comic.chapters, + initialChapter: ep, + initialPage: page, + history: History.fromModel(model: comic, ep: 0, page: 0), + ), + ); + } - void continueRead() {} + void continueRead() { + var ep = history?.ep ?? 1; + var page = history?.page ?? 1; + read(ep, page); + } void download() {} @@ -1117,4 +1134,3 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> { } } } - diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 47cd5df..e2b4f03 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/components/components.dart'; @@ -11,6 +9,7 @@ import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; +import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/io.dart'; @@ -149,7 +148,12 @@ class _HistoryState extends State<_History> { itemBuilder: (context, index) { return InkWell( onTap: () { - // TODO: toComicPageWithHistory(context, history[index]); + context.to( + () => ComicPage( + id: history[index].id, + sourceKey: history[index].type.comicSource!.key, + ), + ); }, borderRadius: BorderRadius.circular(8), child: Container( @@ -177,7 +181,7 @@ class _HistoryState extends State<_History> { ); }, ), - ).paddingHorizontal(8), + ).paddingHorizontal(8).paddingBottom(16), ], ), ), diff --git a/lib/pages/reader/gesture.dart b/lib/pages/reader/gesture.dart new file mode 100644 index 0000000..c3bb3c1 --- /dev/null +++ b/lib/pages/reader/gesture.dart @@ -0,0 +1,165 @@ +part of 'reader.dart'; + +class _ReaderGestureDetector extends StatefulWidget { + const _ReaderGestureDetector({required this.child}); + + final Widget child; + + @override + State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState(); +} + +class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { + late TapGestureRecognizer _tapGestureRecognizer; + + static const _kDoubleTapMinTime = Duration(milliseconds: 200); + + static const _kDoubleTapMaxDistanceSquared = 20.0 * 20.0; + + static const _kTapToTurnPagePercent = 0.3; + + @override + void initState() { + _tapGestureRecognizer = TapGestureRecognizer() + ..onTapUp = onTapUp + ..onSecondaryTapUp = (details) { + onSecondaryTapUp(details.globalPosition); + }; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + _tapGestureRecognizer.addPointer(event); + }, + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + onMouseWheel(event.scrollDelta.dy > 0); + } + }, + child: widget.child, + ); + } + + void onMouseWheel(bool forward) { + if (forward) { + if (!context.reader.toNextPage()) { + context.reader.toNextChapter(); + } + } else { + if (!context.reader.toPrevPage()) { + context.reader.toPrevChapter(); + } + } + } + + TapUpDetails? _previousEvent; + + void onTapUp(TapUpDetails event) { + final location = event.globalPosition; + final previousLocation = _previousEvent?.globalPosition; + if (previousLocation != null) { + if ((location - previousLocation).distanceSquared < + _kDoubleTapMaxDistanceSquared) { + onDoubleTap(location); + _previousEvent = null; + return; + } else { + onTap(previousLocation); + } + } + _previousEvent = event; + Future.delayed(_kDoubleTapMinTime, () { + if (_previousEvent == event) { + onTap(location); + _previousEvent = null; + } + }); + } + + void onTap(Offset location) { + if (context.readerScaffold.isOpen) { + context.readerScaffold.openOrClose(); + } else { + if (appdata.settings['enableTapToTurnPages']) { + bool isLeft = false, isRight = false, isTop = false, isBottom = false; + final width = context.width; + final height = context.height; + final x = location.dx; + final y = location.dy; + if (x < width * _kTapToTurnPagePercent) { + isLeft = true; + } else if (x > width * (1 - _kTapToTurnPagePercent)) { + isRight = true; + } + if (y < height * _kTapToTurnPagePercent) { + isTop = true; + } else if (y > height * (1 - _kTapToTurnPagePercent)) { + isBottom = true; + } + bool isCenter = false; + switch (context.reader.mode) { + case ReaderMode.galleryLeftToRight: + case ReaderMode.continuousLeftToRight: + if (isLeft) { + context.reader.toPrevPage(); + } else if (isRight) { + context.reader.toNextPage(); + } else { + isCenter = true; + } + case ReaderMode.galleryRightToLeft: + case ReaderMode.continuousRightToLeft: + if (isLeft) { + context.reader.toNextPage(); + } else if (isRight) { + context.reader.toPrevPage(); + } else { + isCenter = true; + } + case ReaderMode.galleryTopToBottom: + case ReaderMode.continuousTopToBottom: + if (isTop) { + context.reader.toPrevPage(); + } else if (isBottom) { + context.reader.toNextPage(); + } else { + isCenter = true; + } + } + if (!isCenter) { + return; + } + } + context.readerScaffold.openOrClose(); + } + } + + void onDoubleTap(Offset location) { + context.reader._imageViewController?.handleDoubleTap(location); + } + + void onSecondaryTapUp(Offset location) { + showDesktopMenu( + 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(); + }), + ], + ); + } +} diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart new file mode 100644 index 0000000..0951fb0 --- /dev/null +++ b/lib/pages/reader/images.dart @@ -0,0 +1,212 @@ +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; + + @override + void initState() { + context.reader.isLoading = true; + super.initState(); + } + + void load() async { + if (inProgress) return; + inProgress = true; + var res = await context.reader.widget.source.loadComicPages!( + context.reader.widget.cid, + context.reader.widget.chapters?.keys + .elementAt(context.reader.chapter - 1), + ); + if (res.error) { + setState(() { + error = res.errorMessage; + context.reader.isLoading = false; + inProgress = false; + }); + } else { + setState(() { + context.reader.images = res.data; + context.reader.isLoading = false; + inProgress = false; + }); + } + context.readerScaffold.update(); + } + + @override + Widget build(BuildContext context) { + if (context.reader.isLoading) { + load(); + return const Center( + child: CircularProgressIndicator(), + ); + } else if (error != null) { + return NetworkError( + message: error!, + retry: () { + setState(() { + context.reader.isLoading = true; + error = null; + }); + }, + ); + } else { + if (context.reader.mode.isGallery) { + return _GalleryMode(key: Key(context.reader.mode.key)); + } else { + // TODO: Implement other modes + throw UnimplementedError(); + } + } + } +} + +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 => 4; + + var photoViewControllers = {}; + + @override + void initState() { + controller = PageController(initialPage: context.reader.page); + context.reader._imageViewController = this; + cached = List.filled(context.reader.maxPage + 2, false); + super.initState(); + } + + void cache(int current) { + for (int i = current + 1; i <= current + preCacheCount; i++) { + if (i <= context.reader.maxPage && !cached[i]) { + _precacheImage(i, context); + cached[i] = true; + } + } + } + + @override + Widget build(BuildContext context) { + return PhotoViewGallery.builder( + backgroundDecoration: BoxDecoration( + color: context.colorScheme.surface, + ), + reverse: context.reader.mode == ReaderMode.galleryRightToLeft, + scrollDirection: context.reader.mode == ReaderMode.galleryTopToBottom + ? Axis.vertical + : Axis.horizontal, + itemCount: context.reader.images!.length + 2, + builder: (BuildContext context, int index) { + ImageProvider? imageProvider; + if (index != 0 && index != context.reader.images!.length + 1) { + imageProvider = _createImageProvider(index, context); + } else { + return PhotoViewGalleryPageOptions.customChild( + scaleStateController: PhotoViewScaleStateController(), + child: const SizedBox(), + ); + } + + cached[index] = true; + cache(index); + + photoViewControllers[index] ??= PhotoViewController(); + + return PhotoViewGalleryPageOptions( + filterQuality: FilterQuality.medium, + controller: photoViewControllers[index], + imageProvider: imageProvider, + fit: BoxFit.contain, + errorBuilder: (_, error, s, retry) { + return NetworkError(message: error.toString(), retry: retry); + }, + ); + }, + 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 (!context.reader.toNextChapter()) { + context.reader.toPage(1); + } + } else if (i == context.reader.maxPage + 1) { + if (!context.reader.toPrevChapter()) { + context.reader.toPage(context.reader.maxPage); + } + } else { + context.reader.setPage(i); + context.readerScaffold.update(); + } + }, + ); + } + + @override + Future animateToPage(int page) { + if ((page - controller.page!).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) { + var controller = photoViewControllers[context.reader.page]!; + controller.onDoubleClick?.call(); + } +} + +ImageProvider _createImageProvider(int page, BuildContext context) { + return ReaderImageProvider( + context.reader.images![page - 1], + context.reader.widget.source.key, + context.reader.cid, + context.reader.eid, + ); +} + +void _precacheImage(int page, BuildContext context) { + precacheImage( + _createImageProvider(page, context), + context, + ); +} diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart new file mode 100644 index 0000000..38f303e --- /dev/null +++ b/lib/pages/reader/reader.dart @@ -0,0 +1,275 @@ +library venera_reader; + +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/appdata.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; +import 'package:venera/foundation/history.dart'; +import 'package:venera/foundation/image_provider/reader_image.dart'; +import 'package:venera/utils/translations.dart'; +import 'package:window_manager/window_manager.dart'; + +part 'scaffold.dart'; + +part 'images.dart'; + +part 'gesture.dart'; + +extension _ReaderContext on BuildContext { + _ReaderState get reader => findAncestorStateOfType<_ReaderState>()!; + + _ReaderScaffoldState get readerScaffold => + findAncestorStateOfType<_ReaderScaffoldState>()!; +} + +class Reader extends StatefulWidget { + const Reader({ + super.key, + required this.source, + required this.cid, + required this.name, + required this.chapters, + required this.history, + this.initialPage, + this.initialChapter, + }); + + final ComicSource source; + + final String cid; + + final String name; + + /// Map. + /// null if the comic is a gallery + final Map? chapters; + + /// Starts from 1, invalid values equal to 1 + final int? initialPage; + + /// Starts from 1, invalid values equal to 1 + final int? initialChapter; + + final History history; + + @override + State createState() => _ReaderState(); +} + +class _ReaderState extends State with _ReaderLocation, _ReaderWindow { + @override + void update() { + setState(() {}); + } + + @override + int get maxPage => images?.length ?? 1; + + String get cid => widget.cid; + + String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0'; + + List? images; + + late ReaderMode mode; + + History? history; + + @override + bool isLoading = false; + + @override + void initState() { + page = widget.initialPage ?? 1; + chapter = widget.initialChapter ?? 1; + mode = ReaderMode.fromKey(appdata.settings['readerMode']); + history = widget.history; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return _ReaderScaffold( + child: _ReaderGestureDetector( + child: _ReaderImages(key: Key(chapter.toString())), + ), + ); + } + + @override + int get maxChapter => widget.chapters?.length ?? 1; + + @override + void onPageChanged() { + updateHistory(); + } + + void updateHistory() { + if(history != null) { + history!.page = page; + history!.ep = chapter; + history!.readEpisode.add(chapter); + HistoryManager().addHistory(history!); + } + } +} + +abstract mixin class _ReaderLocation { + int _page = 1; + + int get page => _page; + + set page(int value) { + _page = value; + onPageChanged(); + } + + int chapter = 1; + + int get maxPage; + + int get maxChapter; + + bool get isLoading; + + void update(); + + bool get enablePageAnimation => appdata.settings['enablePageAnimation']; + + _ImageViewController? _imageViewController; + + void onPageChanged(); + + void setPage(int page) { + // Prevent page change during animation + if (_animationCount > 0) { + return; + } + this.page = page; + } + + bool _validatePage(int page) { + return page >= 1 && page <= maxPage; + } + + /// Returns true if the page is changed + bool toNextPage() { + return toPage(page + 1); + } + + /// Returns true if the page is changed + bool toPrevPage() { + return toPage(page - 1); + } + + int _animationCount = 0; + + bool toPage(int page) { + if (_validatePage(page)) { + if (page == this.page) { + return false; + } + this.page = page; + update(); + if (enablePageAnimation) { + _animationCount++; + _imageViewController!.animateToPage(page).then((_) { + _animationCount--; + }); + } else { + _imageViewController!.toPage(page); + } + return true; + } + return false; + } + + bool _validateChapter(int chapter) { + return chapter >= 1 && chapter <= maxChapter; + } + + /// Returns true if the chapter is changed + bool toNextChapter() { + return toChapter(chapter + 1); + } + + /// Returns true if the chapter is changed + bool toPrevChapter() { + return toChapter(chapter - 1); + } + + bool toChapter(int c) { + if (_validateChapter(c) && !isLoading) { + chapter = c; + page = 1; + update(); + return true; + } + return false; + } + + Timer? autoPageTurningTimer; + + void autoPageTurning() { + if (autoPageTurningTimer != null) { + autoPageTurningTimer!.cancel(); + autoPageTurningTimer = null; + } else { + int interval = appdata.settings['autoPageTurningInterval']; + autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) { + if (page == maxPage) { + autoPageTurningTimer!.cancel(); + } + toNextPage(); + }); + } + } +} + +mixin class _ReaderWindow { + bool isFullscreen = false; + + void fullscreen() { + windowManager.setFullScreen(!isFullscreen); + isFullscreen = !isFullscreen; + } +} + +enum ReaderMode { + galleryLeftToRight('galleryLeftToRight'), + galleryRightToLeft('galleryRightToLeft'), + galleryTopToBottom('galleryTopToBottom'), + continuousTopToBottom('continuousTopToBottom'), + continuousLeftToRight('continuousLeftToRight'), + continuousRightToLeft('continuousRightToLeft'); + + final String key; + + bool get isGallery => key.startsWith('gallery'); + + const ReaderMode(this.key); + + static ReaderMode fromKey(String key) { + for (var mode in values) { + if (mode.key == key) { + return mode; + } + } + return galleryLeftToRight; + } +} + +abstract interface class _ImageViewController { + void toPage(int page); + + Future animateToPage(int page); + + void handleDoubleTap(Offset location); +} diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart new file mode 100644 index 0000000..0c78327 --- /dev/null +++ b/lib/pages/reader/scaffold.dart @@ -0,0 +1,302 @@ +part of 'reader.dart'; + +class _ReaderScaffold extends StatefulWidget { + const _ReaderScaffold({required this.child}); + + final Widget child; + + @override + State<_ReaderScaffold> createState() => _ReaderScaffoldState(); +} + +class _ReaderScaffoldState extends State<_ReaderScaffold> { + bool _isOpen = false; + + static const kTopBarHeight = 56.0; + + static const kBottomBarHeight = 105.0; + + bool get isOpen => _isOpen; + + void openOrClose() { + setState(() { + _isOpen = !_isOpen; + }); + } + + bool? rotation; + + void update() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: widget.child, + ), + buildPageInfoText(), + AnimatedPositioned( + duration: const Duration(milliseconds: 180), + top: _isOpen ? 0 : -(kTopBarHeight + context.padding.top), + left: 0, + right: 0, + height: kTopBarHeight + context.padding.top, + child: buildTop(), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 180), + bottom: _isOpen ? 0 : -(kBottomBarHeight + context.padding.bottom), + left: 0, + right: 0, + height: kBottomBarHeight + context.padding.bottom, + child: buildBottom(), + ), + ], + ); + } + + Widget buildTop() { + return BlurEffect( + child: Container( + padding: EdgeInsets.only(top: context.padding.top), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.grey.withOpacity(0.5), + width: 0.5, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 8), + Expanded( + child: Text(context.reader.widget.name, style: ts.s18), + ), + ], + ), + ), + ); + } + + Widget buildBottom() { + var text = "E${context.reader.chapter} : P${context.reader.page}"; + if (context.reader.widget.chapters == null) { + text = "P${context.reader.page}"; + } + + Widget child = SizedBox( + height: kBottomBarHeight + MediaQuery.of(context).padding.bottom, + child: Column( + children: [ + const SizedBox( + height: 8, + ), + Row( + children: [ + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: context.reader.toPrevChapter, + icon: const Icon(Icons.first_page), + ), + Expanded( + child: buildSlider(), + ), + IconButton.filledTonal( + onPressed: context.reader.toNextChapter, + icon: const Icon(Icons.last_page)), + const SizedBox( + width: 8, + ), + ], + ), + Row( + children: [ + const SizedBox( + width: 16, + ), + Container( + height: 24, + padding: const EdgeInsets.fromLTRB(6, 2, 6, 0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text(text), + ), + const Spacer(), + if (App.isWindows) + Tooltip( + message: "${"Full Screen".tl}(F12)", + child: IconButton( + icon: const Icon(Icons.fullscreen), + onPressed: () { + context.reader.fullscreen(); + }, + ), + ), + if (App.isAndroid) + Tooltip( + message: "Screen Rotation".tl, + child: IconButton( + icon: () { + if (rotation == null) { + return const Icon(Icons.screen_rotation); + } else if (rotation == false) { + return const Icon(Icons.screen_lock_portrait); + } else { + return const Icon(Icons.screen_lock_landscape); + } + }.call(), + onPressed: () { + if (rotation == null) { + setState(() { + rotation = false; + }); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } else if (rotation == false) { + setState(() { + rotation = true; + }); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight + ]); + } else { + setState(() { + rotation = null; + }); + SystemChrome.setPreferredOrientations( + DeviceOrientation.values); + } + }, + ), + ), + Tooltip( + message: "Auto Page Turning".tl, + child: IconButton( + icon: context.reader.autoPageTurningTimer != null + ? const Icon(Icons.timer) + : const Icon(Icons.timer_sharp), + onPressed: context.reader.autoPageTurning, + ), + ), + if (context.reader.widget.chapters != null) + Tooltip( + message: "Chapters".tl, + child: IconButton( + icon: const Icon(Icons.library_books), + onPressed: openChapterDrawer, + ), + ), + Tooltip( + message: "Save Image".tl, + child: IconButton( + icon: const Icon(Icons.download), + onPressed: saveCurrentImage, + ), + ), + Tooltip( + message: "Share".tl, + child: IconButton( + icon: const Icon(Icons.share), + onPressed: share, + ), + ), + const SizedBox(width: 4) + ], + ) + ], + ), + ); + + return BlurEffect( + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Colors.grey.withOpacity(0.5), + width: 0.5, + ), + ), + ), + padding: EdgeInsets.only(bottom: context.padding.bottom), + child: child, + ), + ); + } + + Widget buildSlider() { + return Slider( + value: context.reader.page.toDouble(), + min: 1, + max: context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(), + divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16), + onChanged: (i) { + context.reader.toPage(i.toInt()); + }, + ); + } + + Widget buildPageInfoText() { + var epName = context.reader.widget.chapters?.values + .elementAt(context.reader.chapter - 1) ?? + "E${context.reader.chapter}"; + if (epName.length > 8) { + epName = "${epName.substring(0, 8)}..."; + } + var pageText = "${context.reader.page}/${context.reader.maxPage}"; + var text = context.reader.widget.chapters != null + ? "$epName : $pageText" + : pageText; + + return Positioned( + bottom: 13, + left: 25, + child: Stack( + children: [ + Text( + text, + style: TextStyle( + fontSize: 14, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4 + ..color = context.colorScheme.onInverseSurface, + ), + ), + Text(text), + ], + ), + ); + } + + void openChapterDrawer() { + // TODO + } + + void saveCurrentImage() { + // TODO + } + + void share() { + // TODO + } + + void openSetting() { + // TODO + } +} diff --git a/pubspec.lock b/pubspec.lock index 8de9128..31bab90 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -302,6 +302,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + photo_view: + dependency: "direct main" + description: + path: "." + ref: "94724a0b" + resolved-ref: "94724a0b7f94167fd1ae061f84e14ae04cae5c39" + url: "https://github.com/wgh136/photo_view" + source: git + version: "0.14.0" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9a012b5..2e46a11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,10 @@ dependencies: file_picker: ^8.1.2 url_launcher: ^6.3.0 path: ^1.9.0 + photo_view: + git: + url: https://github.com/wgh136/photo_view + ref: 94724a0b dev_dependencies: flutter_test: