Files
venera/lib/pages/reader/reader.dart
角砂糖 4eff50dbed Fix history of maxPage when maxPage in reader is 1 (#220)
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.
2025-02-21 14:25:38 +08:00

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);
}