library; import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; 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:flutter_memory_info/flutter_memory_info.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/components/custom_slider.dart'; import 'package:venera/components/window_frame.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/global_state.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/images.dart'; import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/clipboard_image.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; import 'package:venera/utils/volume.dart'; import 'package:window_manager/window_manager.dart'; import 'package:battery_plus/battery_plus.dart'; part 'scaffold.dart'; part 'images.dart'; part 'gesture.dart'; part 'comic_image.dart'; part 'loading.dart'; part 'chapters.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.type, required this.cid, required this.name, required this.chapters, required this.history, this.initialPage, this.initialChapter, this.initialChapterGroup, required this.author, required this.tags, }); final ComicType type; final String author; final List tags; final String cid; final String name; final ComicChapters? chapters; /// Starts from 1, invalid values equal to 1 final int? initialPage; /// Starts from 1, invalid values equal to 1 final int? initialChapter; /// Starts from 1, invalid values equal to 1 final int? initialChapterGroup; final History history; @override State createState() => _ReaderState(); } class _ReaderState extends State with _ReaderLocation, _ReaderWindow, _VolumeListener, _ImagePerPageHandler { @override void update() { setState(() {}); } @override int get maxPage { if (!showSingleImageOnFirstPage) { return (images!.length / imagesPerPage).ceil(); } else { return 1 + ((images!.length - 1) / imagesPerPage).ceil(); } } ComicType get type => widget.type; String get cid => widget.cid; String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0'; List? images; @override late ReaderMode mode; @override bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait; History? history; @override bool isLoading = false; var focusNode = FocusNode(); @override void initState() { page = widget.initialPage ?? 1; if (page < 1) { page = 1; } chapter = widget.initialChapter ?? 1; if (chapter < 1) { chapter = 1; } if (widget.initialChapterGroup != null) { for (int i = 0; i < (widget.initialChapterGroup! - 1); i++) { chapter += widget.chapters!.getGroupByIndex(i).length; } } if (widget.initialPage != null) { page = widget.initialPage!; } mode = ReaderMode.fromKey(appdata.settings['readerMode']); history = widget.history; Future.microtask(() { updateHistory(); }); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); if (appdata.settings['enableTurnPageByVolumeKey']) { handleVolumeEvent(); } setImageCacheSize(); Future.delayed(const Duration(milliseconds: 200), () { LocalFavoritesManager().onRead(cid, type); }); super.initState(); } @override void didChangeDependencies() { super.didChangeDependencies(); initImagesPerPage(widget.initialPage ?? 1); initReaderWindow(); } void setImageCacheSize() async { var availableRAM = await MemoryInfo.getFreePhysicalMemorySize(); if (availableRAM == null) return; int maxImageCacheSize; if (availableRAM < 1 << 30) { maxImageCacheSize = 100 << 20; } else if (availableRAM < 2 << 30) { maxImageCacheSize = 200 << 20; } else if (availableRAM < 4 << 30) { maxImageCacheSize = 300 << 20; } else { maxImageCacheSize = 500 << 20; } Log.info("Reader", "Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize"); PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize; } @override void dispose() { if (isFullscreen) { fullscreen(); } autoPageTurningTimer?.cancel(); focusNode.dispose(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); stopVolumeEvent(); Future.microtask(() { DataSync().onDataChanged(); }); PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; disposeReaderWindow(); super.dispose(); } @override Widget build(BuildContext context) { _checkImagesPerPageChange(); return KeyboardListener( focusNode: focusNode, autofocus: true, onKeyEvent: onKeyEvent, child: Overlay( initialEntries: [ OverlayEntry(builder: (context) { return _ReaderScaffold( child: _ReaderGestureDetector( child: _ReaderImages(key: Key(chapter.toString())), ), ); }) ], ), ); } void onKeyEvent(KeyEvent event) { if (event.logicalKey == LogicalKeyboardKey.f12 && event is KeyUpEvent) { fullscreen(); } _imageViewController?.handleKeyEvent(event); } @override int get maxChapter => widget.chapters?.length ?? 1; @override void onPageChanged() { updateHistory(); } /// Prevent multiple history updates in a short time. /// `HistoryManager().addHistoryAsync` is a high-cost operation because it creates a new isolate. Timer? _updateHistoryTimer; void updateHistory() { if (history != null) { if (page == maxPage) { /// Record the last image of chapter history!.page = images?.length ?? 1; } else { /// Record the first image of the page history!.page = (page - 1) * imagesPerPage + 1; } history!.maxPage = images?.length ?? 1; if (widget.chapters?.isGrouped ?? false) { int g = 0; int c = chapter; while (c > widget.chapters!.getGroupByIndex(g).length) { c -= widget.chapters!.getGroupByIndex(g).length; g++; } history!.readEpisode.add('${g + 1}-$c'); history!.ep = c; history!.group = g + 1; } else { history!.readEpisode.add(chapter.toString()); history!.ep = chapter; } history!.time = DateTime.now(); _updateHistoryTimer?.cancel(); _updateHistoryTimer = Timer(const Duration(seconds: 1), () { HistoryManager().addHistoryAsync(history!); _updateHistoryTimer = null; }); } } bool get isFirstChapterOfGroup { if (widget.chapters?.isGrouped ?? false) { int c = chapter - 1; int g = 1; while (c > 0) { c -= widget.chapters!.getGroupByIndex(g - 1).length; g++; } if (c == 0) { return true; } else { return false; } } return chapter == 1; } bool get isLastChapterOfGroup { if (widget.chapters?.isGrouped ?? false) { int c = chapter; int g = 1; while (c > 0) { c -= widget.chapters!.getGroupByIndex(g - 1).length; g++; } if (c == 0) { return true; } else { return false; } } return chapter == maxChapter; } /// Get the size of the reader. /// The size is not always the same as the size of the screen. Size get size { var renderBox = context.findRenderObject() as RenderBox; return renderBox.size; } } abstract mixin class _ImagePerPageHandler { late int _lastImagesPerPage; bool get isPortrait; int get page; set page(int value); ReaderMode get mode; void initImagesPerPage(int initialPage) { _lastImagesPerPage = imagesPerPage; if (imagesPerPage != 1) { page = (initialPage / imagesPerPage).ceil(); } } bool get showSingleImageOnFirstPage => appdata.settings["showSingleImageOnFirstPage"]; /// The number of images displayed on one screen int get imagesPerPage { if (mode.isContinuous) return 1; if (isPortrait) { return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1; } else { return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1; } } /// Check if the number of images per page has changed void _checkImagesPerPageChange() { int currentImagesPerPage = imagesPerPage; if (_lastImagesPerPage != currentImagesPerPage) { _adjustPageForImagesPerPageChange( _lastImagesPerPage, currentImagesPerPage); _lastImagesPerPage = currentImagesPerPage; } } /// Adjust the page number when the number of images per page changes void _adjustPageForImagesPerPageChange( int oldImagesPerPage, int newImagesPerPage) { int previousImageIndex = (page - 1) * oldImagesPerPage; int newPage = (previousImageIndex ~/ newImagesPerPage) + 1; page = newPage; } } abstract mixin class _VolumeListener { bool toNextPage(); bool toPrevPage(); bool toNextChapter(); bool toPrevChapter(); VolumeListener? volumeListener; void onDown() { if (!toNextPage()) { toNextChapter(); } } void onUp() { if (!toPrevPage()) { toPrevChapter(); } } void handleVolumeEvent() { if (!App.isAndroid) { // Currently only support Android return; } if (volumeListener != null) { volumeListener?.cancel(); } volumeListener = VolumeListener( onDown: onDown, onUp: onUp, )..listen(); } void stopVolumeEvent() { if (volumeListener != null) { volumeListener?.cancel(); volumeListener = null; } } } 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 && page != 1 && page != maxPage) { 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; late WindowFrameController windowFrame; bool _isInit = false; void initReaderWindow() { if (!App.isDesktop || _isInit) return; windowFrame = WindowFrame.of(App.rootContext); windowFrame.addCloseListener(onWindowClose); _isInit = true; } void fullscreen() async { if (!App.isDesktop) return; await windowManager.hide(); await windowManager.setFullScreen(!isFullscreen); await windowManager.show(); isFullscreen = !isFullscreen; WindowFrame.of(App.rootContext).setWindowFrame(!isFullscreen); } bool onWindowClose() { if (Navigator.of(App.rootContext).canPop()) { Navigator.of(App.rootContext).pop(); return false; } else { return true; } } void disposeReaderWindow() { if (!App.isDesktop) return; windowFrame.removeCloseListener(onWindowClose); } } enum ReaderMode { galleryLeftToRight('galleryLeftToRight'), galleryRightToLeft('galleryRightToLeft'), galleryTopToBottom('galleryTopToBottom'), continuousTopToBottom('continuousTopToBottom'), continuousLeftToRight('continuousLeftToRight'), continuousRightToLeft('continuousRightToLeft'); final String key; bool get isGallery => key.startsWith('gallery'); bool get isContinuous => key.startsWith('continuous'); 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); void handleLongPressDown(Offset location); void handleLongPressUp(Offset location); void handleKeyEvent(KeyEvent event); /// Returns true if the event is handled. bool handleOnTap(Offset location); Future getImageByOffset(Offset offset); String? getImageKeyByOffset(Offset offset); }