mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00

Due to the change of page and maxPage before, the history of maxPage should be real maxPage. If not, when maxPage in reader is 1, the maxPage in history will be none or the last ep's real maxPage.
544 lines
13 KiB
Dart
544 lines
13 KiB
Dart
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/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/pages/settings/settings_page.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';
|
|
|
|
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<String> 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<Reader> createState() => _ReaderState();
|
|
}
|
|
|
|
class _ReaderState extends State<Reader>
|
|
with _ReaderLocation, _ReaderWindow, _VolumeListener, _ImagePerPageHandler {
|
|
@override
|
|
void update() {
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
|
|
|
|
ComicType get type => widget.type;
|
|
|
|
String get cid => widget.cid;
|
|
|
|
String get eid => widget.chapters?.ids.elementAt(chapter - 1) ?? '0';
|
|
|
|
List<String>? 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);
|
|
}
|
|
|
|
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() {
|
|
autoPageTurningTimer?.cancel();
|
|
focusNode.dispose();
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
stopVolumeEvent();
|
|
Future.microtask(() {
|
|
DataSync().onDataChanged();
|
|
});
|
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20;
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
_checkImagesPerPageChange();
|
|
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;
|
|
|
|
@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;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
|
|
VolumeListener? volumeListener;
|
|
|
|
void handleVolumeEvent() {
|
|
if (!App.isAndroid) {
|
|
// Currently only support Android
|
|
return;
|
|
}
|
|
if (volumeListener != null) {
|
|
volumeListener?.cancel();
|
|
}
|
|
volumeListener = VolumeListener(
|
|
onDown: toNextPage,
|
|
onUp: toPrevPage,
|
|
)..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) {
|
|
if (!(chapter == 1 && page == 1) &&
|
|
!(chapter == maxChapter && 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;
|
|
|
|
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');
|
|
|
|
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<void> 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);
|
|
}
|