generated from dellevin/template
原生epub阅读
This commit is contained in:
57
lib/pages/epub_reader/mixins/footnote_mixin.dart
Normal file
57
lib/pages/epub_reader/mixins/footnote_mixin.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
part of '../reader_screen.dart';
|
||||
|
||||
mixin _FootnoteMixin on State<ReaderScreen> {
|
||||
// === Borrowed state (provided by _ReaderScreenState fields) ===
|
||||
OverlayEntry? get footnoteOverlayEntry;
|
||||
set footnoteOverlayEntry(OverlayEntry? v);
|
||||
|
||||
GlobalKey<FootnotePopupOverlayState> get footnoteKey;
|
||||
|
||||
bool get isClosingFootnote;
|
||||
set isClosingFootnote(bool v);
|
||||
|
||||
EpubWebViewHandler get webViewHandler;
|
||||
|
||||
BookSession get bookSession;
|
||||
|
||||
ReaderSettings get readerSettings;
|
||||
|
||||
// === Cross-mixin: _ThemeMixin ===
|
||||
EpubTheme getEpubTheme();
|
||||
|
||||
void handleFootnoteTap(String innerHtml, Rect rect, String baseUrl) {
|
||||
removeFootnoteOverlay();
|
||||
final overlayState = Overlay.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
setState(() {
|
||||
footnoteOverlayEntry = OverlayEntry(
|
||||
builder: (context) => FootnotePopupOverlay(
|
||||
key: footnoteKey,
|
||||
anchorRect: rect,
|
||||
rawHtml: innerHtml,
|
||||
onDismiss: () => removeFootnoteOverlay(),
|
||||
colorScheme: colorScheme,
|
||||
zoom: readerSettings.zoom,
|
||||
),
|
||||
);
|
||||
});
|
||||
overlayState.insert(footnoteOverlayEntry!);
|
||||
}
|
||||
|
||||
Future<void> removeFootnoteOverlay({bool animate = true}) async {
|
||||
if (footnoteOverlayEntry == null || isClosingFootnote) return;
|
||||
|
||||
if (animate) {
|
||||
isClosingFootnote = true;
|
||||
if (footnoteKey.currentState != null) {
|
||||
await footnoteKey.currentState!.playReverseAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
footnoteOverlayEntry?.remove();
|
||||
setState(() {
|
||||
footnoteOverlayEntry = null;
|
||||
isClosingFootnote = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
47
lib/pages/epub_reader/mixins/image_viewer_mixin.dart
Normal file
47
lib/pages/epub_reader/mixins/image_viewer_mixin.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
part of '../reader_screen.dart';
|
||||
|
||||
mixin _ImageViewerMixin on State<ReaderScreen> {
|
||||
// === Borrowed state (provided by _ReaderScreenState fields) ===
|
||||
BookSession get bookSession;
|
||||
|
||||
bool get showControls;
|
||||
|
||||
bool get isImageViewerVisible;
|
||||
set isImageViewerVisible(bool v);
|
||||
|
||||
Uint8List? get currentImageData;
|
||||
set currentImageData(Uint8List? v);
|
||||
|
||||
Rect? get currentImageRect;
|
||||
set currentImageRect(Rect? v);
|
||||
|
||||
Future<void> handleImageLongPress(String imageUrl, Rect rect) async {
|
||||
if (!bookSession.isLoaded) return;
|
||||
if (showControls) return;
|
||||
|
||||
// Resolve image bytes from the epub via webViewHandler
|
||||
final data = await webViewHandler.resolveImageFromEpub(
|
||||
epubPath: bookSession.book['file_path'] as String? ?? '',
|
||||
imageUrl: imageUrl,
|
||||
fileHash: widget.bookId,
|
||||
);
|
||||
if (data == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
currentImageData = data;
|
||||
currentImageRect = rect;
|
||||
isImageViewerVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
void closeImageViewer() {
|
||||
setState(() {
|
||||
isImageViewerVisible = false;
|
||||
currentImageData = null;
|
||||
currentImageRect = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Cross-reference: webViewHandler is defined in _ReaderScreenState
|
||||
EpubWebViewHandler get webViewHandler;
|
||||
}
|
||||
82
lib/pages/epub_reader/mixins/link_handling_mixin.dart
Normal file
82
lib/pages/epub_reader/mixins/link_handling_mixin.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
part of '../reader_screen.dart';
|
||||
|
||||
mixin _LinkHandlingMixin on State<ReaderScreen> {
|
||||
// === Borrowed state (provided by _ReaderScreenState fields) ===
|
||||
BookSession get bookSession;
|
||||
|
||||
ReaderSettings get readerSettings;
|
||||
|
||||
// === Cross-mixin: _SpineNavigationMixin ===
|
||||
Future<void> loadCarousel({
|
||||
String anchor = 'top',
|
||||
int? overrideSpineIndex,
|
||||
double? restoreScrollRatio,
|
||||
});
|
||||
|
||||
// === Cross-mixin: _ThemeMixin ===
|
||||
EpubTheme getEpubTheme();
|
||||
|
||||
bool shouldHandleLinkTap(String url) {
|
||||
if (url.startsWith('epub://')) {
|
||||
final index = bookSession.findSpineIndexByUrl(url);
|
||||
return index != null;
|
||||
} else {
|
||||
return readerSettings.linkHandling != ReaderLinkHandling.never;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleLinkTap(String url) async {
|
||||
if (url.startsWith('epub://')) {
|
||||
final index = bookSession.findSpineIndexByUrl(url);
|
||||
if (index != null) {
|
||||
String anchor = 'top';
|
||||
if (url.contains('#')) {
|
||||
anchor = url.split('#').last;
|
||||
}
|
||||
await loadCarousel(anchor: anchor, overrideSpineIndex: index);
|
||||
}
|
||||
} else {
|
||||
final linkHandling = readerSettings.linkHandling;
|
||||
final uri = Uri.tryParse(url);
|
||||
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
if (linkHandling == ReaderLinkHandling.always) {
|
||||
await launchUrl(uri);
|
||||
} else if (linkHandling == ReaderLinkHandling.ask) {
|
||||
if (mounted && context.mounted) {
|
||||
final shouldOpen =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('打开外部链接'),
|
||||
content: Text('是否打开链接: $url'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('打开'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (shouldOpen) {
|
||||
await launchUrl(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ReaderLinkHandling.never: do nothing
|
||||
} else {
|
||||
if (mounted && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('无法打开链接: $url')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
lib/pages/epub_reader/mixins/page_navigation_mixin.dart
Normal file
88
lib/pages/epub_reader/mixins/page_navigation_mixin.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
part of '../reader_screen.dart';
|
||||
|
||||
mixin _PageNavigationMixin on State<ReaderScreen> {
|
||||
// === Borrowed state (provided by _ReaderScreenState fields) ===
|
||||
int get currentPageInChapter;
|
||||
set currentPageInChapter(int v);
|
||||
|
||||
int get totalPagesInChapter;
|
||||
set totalPagesInChapter(int v);
|
||||
|
||||
int get currentSpineItemIndex;
|
||||
|
||||
BookSession get bookSession;
|
||||
|
||||
ReaderRendererController get rendererController;
|
||||
|
||||
// === Cross-mixin: _SpineNavigationMixin ===
|
||||
Future<void> nextSpineItem();
|
||||
Future<void> previousSpineItem();
|
||||
|
||||
// === Cross-mixin: _ProgressMixin ===
|
||||
void updateProgressDebounced();
|
||||
void saveProgress();
|
||||
|
||||
// === Cross-mixin: _ThemeMixin ===
|
||||
EpubTheme getEpubTheme();
|
||||
|
||||
bool canPerformPageTurn(bool isNext) {
|
||||
if (isNext) {
|
||||
if (currentPageInChapter >= totalPagesInChapter - 1 &&
|
||||
currentSpineItemIndex >= bookSession.spine.length - 1) {
|
||||
_showToast('已经是最后一页');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (currentPageInChapter <= 0 && currentSpineItemIndex <= 0) {
|
||||
_showToast('已经是第一页');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> handlePageTurn(bool isNext) async {
|
||||
if (isNext) {
|
||||
await nextPage();
|
||||
} else {
|
||||
await previousPage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> goToPage(int pageIndex) async {
|
||||
if (pageIndex < 0 || pageIndex >= totalPagesInChapter) return;
|
||||
|
||||
setState(() {
|
||||
currentPageInChapter = pageIndex;
|
||||
});
|
||||
updateProgressDebounced();
|
||||
|
||||
await rendererController.jumpToPage(pageIndex);
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
Future<void> nextPage() async {
|
||||
if (currentPageInChapter < totalPagesInChapter - 1) {
|
||||
await goToPage(currentPageInChapter + 1);
|
||||
} else {
|
||||
await nextSpineItem();
|
||||
}
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
Future<void> previousPage() async {
|
||||
if (currentPageInChapter > 0) {
|
||||
await goToPage(currentPageInChapter - 1);
|
||||
} else {
|
||||
await previousSpineItem();
|
||||
}
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
void _showToast(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/pages/epub_reader/mixins/progress_mixin.dart
Normal file
45
lib/pages/epub_reader/mixins/progress_mixin.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
part of '../reader_screen.dart';
|
||||
|
||||
mixin _ProgressMixin on State<ReaderScreen> {
|
||||
// === Borrowed state (provided by _ReaderScreenState fields) ===
|
||||
int get totalPagesInChapter;
|
||||
|
||||
int get currentPageInChapter;
|
||||
|
||||
int get currentSpineItemIndex;
|
||||
|
||||
BookSession get bookSession;
|
||||
|
||||
bool get isWebViewLoading;
|
||||
|
||||
String get displayProgress;
|
||||
set displayProgress(String v);
|
||||
|
||||
Timer? get progressDebouncer;
|
||||
set progressDebouncer(Timer? v);
|
||||
|
||||
void updateProgressDebounced() {
|
||||
progressDebouncer?.cancel();
|
||||
progressDebouncer = Timer(const Duration(milliseconds: 150), () {
|
||||
if (!mounted) return;
|
||||
if (isWebViewLoading) return;
|
||||
|
||||
final pageInChapterStr =
|
||||
'${currentPageInChapter + 1}/$totalPagesInChapter';
|
||||
|
||||
if (displayProgress != pageInChapterStr) {
|
||||
setState(() {
|
||||
displayProgress = pageInChapterStr;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void saveProgress() {
|
||||
bookSession.saveProgress(
|
||||
currentChapterIndex: currentSpineItemIndex,
|
||||
currentPageInChapter: currentPageInChapter,
|
||||
totalPagesInChapter: totalPagesInChapter,
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/pages/epub_reader/mixins/spine_navigation_mixin.dart
Normal file
239
lib/pages/epub_reader/mixins/spine_navigation_mixin.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
part of '../reader_screen.dart';
|
||||
|
||||
mixin _SpineNavigationMixin on State<ReaderScreen> {
|
||||
// === Borrowed state (provided by _ReaderScreenState fields) ===
|
||||
BookSession get bookSession;
|
||||
|
||||
ReaderRendererController get rendererController;
|
||||
|
||||
bool get isWebViewLoading;
|
||||
set isWebViewLoading(bool v);
|
||||
|
||||
int get currentSpineItemIndex;
|
||||
set currentSpineItemIndex(int v);
|
||||
|
||||
int get currentPageInChapter;
|
||||
set currentPageInChapter(int v);
|
||||
|
||||
// === Cross-mixin: _ProgressMixin ===
|
||||
void updateProgressDebounced();
|
||||
void saveProgress();
|
||||
|
||||
// === Cross-mixin: _ThemeMixin ===
|
||||
EpubTheme getEpubTheme();
|
||||
|
||||
List<String> getAnchorsForSpine(String spinePath) {
|
||||
return bookSession.getAnchorsForSpine(spinePath);
|
||||
}
|
||||
|
||||
void handleScrollAnchors(List<String> anchorIds) {
|
||||
setState(() {
|
||||
bookSession.updateActiveAnchors(anchorIds);
|
||||
});
|
||||
}
|
||||
|
||||
String getSpineItemUrl(int index, [String anchor = 'top']) {
|
||||
return bookSession.getSpineItemUrl(index, anchor);
|
||||
}
|
||||
|
||||
String? getSpineProperties(int index) {
|
||||
return bookSession.getSpineProperties(index);
|
||||
}
|
||||
|
||||
Future<void> loadCarousel({
|
||||
String anchor = 'top',
|
||||
int? overrideSpineIndex,
|
||||
double? restoreScrollRatio,
|
||||
}) async {
|
||||
if (bookSession.spine.isEmpty) return;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isWebViewLoading = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (overrideSpineIndex != null &&
|
||||
overrideSpineIndex >= 0 &&
|
||||
overrideSpineIndex < bookSession.spine.length) {
|
||||
currentSpineItemIndex = overrideSpineIndex;
|
||||
}
|
||||
final currIndex = currentSpineItemIndex;
|
||||
final prevIndex = currIndex > 0 ? currIndex - 1 : null;
|
||||
final nextIndex = currIndex < bookSession.spine.length - 1
|
||||
? currIndex + 1
|
||||
: null;
|
||||
|
||||
final tokensForWait = <int>[];
|
||||
|
||||
final currUrl = getSpineItemUrl(currIndex, anchor);
|
||||
final currentSpinePath = bookSession.spine[currIndex].href;
|
||||
final currToken = await rendererController.preloadCurrentChapter(
|
||||
currUrl,
|
||||
getAnchorsForSpine(currentSpinePath),
|
||||
getSpineProperties(currIndex),
|
||||
);
|
||||
if (currToken != null) tokensForWait.add(currToken);
|
||||
|
||||
if (prevIndex != null) {
|
||||
final prevUrl = getSpineItemUrl(prevIndex);
|
||||
final prevSpinePath = bookSession.spine[prevIndex].href;
|
||||
final prevToken = await rendererController.preloadPreviousChapter(
|
||||
prevUrl,
|
||||
getAnchorsForSpine(prevSpinePath),
|
||||
getSpineProperties(prevIndex),
|
||||
);
|
||||
if (prevToken != null) tokensForWait.add(prevToken);
|
||||
}
|
||||
|
||||
if (nextIndex != null) {
|
||||
final nextUrl = getSpineItemUrl(nextIndex);
|
||||
final nextSpinePath = bookSession.spine[nextIndex].href;
|
||||
final nextToken = await rendererController.preloadNextChapter(
|
||||
nextUrl,
|
||||
getAnchorsForSpine(nextSpinePath),
|
||||
getSpineProperties(nextIndex),
|
||||
);
|
||||
if (nextToken != null) tokensForWait.add(nextToken);
|
||||
}
|
||||
|
||||
await rendererController.waitForEvents(tokensForWait);
|
||||
|
||||
if (restoreScrollRatio != null) {
|
||||
await rendererController.restoreScrollPosition(restoreScrollRatio);
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 30));
|
||||
setState(() {
|
||||
isWebViewLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> preloadNextOf(int currentIndex) async {
|
||||
final nextIndex = currentIndex + 1;
|
||||
if (nextIndex < bookSession.spine.length) {
|
||||
final url = getSpineItemUrl(nextIndex);
|
||||
final nextSpinePath = bookSession.spine[nextIndex].href;
|
||||
await rendererController.preloadNextChapter(
|
||||
url,
|
||||
getAnchorsForSpine(nextSpinePath),
|
||||
getSpineProperties(nextIndex),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preloadPreviousOf(int currentIndex) async {
|
||||
final prevIndex = currentIndex - 1;
|
||||
if (prevIndex >= 0) {
|
||||
final url = getSpineItemUrl(prevIndex);
|
||||
final prevSpinePath = bookSession.spine[prevIndex].href;
|
||||
await rendererController.preloadPreviousChapter(
|
||||
url,
|
||||
getAnchorsForSpine(prevSpinePath),
|
||||
getSpineProperties(prevIndex),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> navigateToSpineItem(int index, [String anchor = 'top']) async {
|
||||
if (index < 0 || index >= bookSession.spine.length) return;
|
||||
|
||||
setState(() {
|
||||
currentSpineItemIndex = index;
|
||||
currentPageInChapter = 0;
|
||||
});
|
||||
updateProgressDebounced();
|
||||
|
||||
await loadCarousel(anchor: anchor);
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
Future<void> previousSpineItem() async {
|
||||
if (currentSpineItemIndex <= 0) {
|
||||
_showToast('已经是第一章');
|
||||
return;
|
||||
}
|
||||
|
||||
await rendererController.jumpToPreviousChapterLastPage();
|
||||
|
||||
setState(() {
|
||||
currentSpineItemIndex--;
|
||||
});
|
||||
|
||||
preloadPreviousOf(currentSpineItemIndex);
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
Future<void> previousSpineItemFirstPage() async {
|
||||
if (currentSpineItemIndex <= 0) {
|
||||
_showToast('已经是第一章');
|
||||
return;
|
||||
}
|
||||
|
||||
await rendererController.jumpToPreviousChapterFirstPage();
|
||||
|
||||
setState(() {
|
||||
currentSpineItemIndex--;
|
||||
currentPageInChapter = 0;
|
||||
});
|
||||
updateProgressDebounced();
|
||||
|
||||
preloadPreviousOf(currentSpineItemIndex);
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
Future<void> nextSpineItem() async {
|
||||
if (currentSpineItemIndex >= bookSession.spine.length - 1) {
|
||||
_showToast('已经是最后一章');
|
||||
return;
|
||||
}
|
||||
|
||||
await rendererController.jumpToNextChapter();
|
||||
|
||||
setState(() {
|
||||
currentSpineItemIndex++;
|
||||
currentPageInChapter = 0;
|
||||
});
|
||||
updateProgressDebounced();
|
||||
|
||||
preloadNextOf(currentSpineItemIndex);
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
Future<void> navigateToTocItem(TocEntry item) async {
|
||||
final targetHref = bookSession.findFirstValidHref(item);
|
||||
|
||||
if (targetHref == null) {
|
||||
_showToast('该章节无内容');
|
||||
return;
|
||||
}
|
||||
|
||||
final index = bookSession.findSpineIndexForTocItem(item);
|
||||
|
||||
if (index != null) {
|
||||
final anchor = targetHref.href.contains('#')
|
||||
? targetHref.href.split('#').last
|
||||
: 'top';
|
||||
await navigateToSpineItem(index, anchor);
|
||||
} else {
|
||||
_showToast('目录章节未找到');
|
||||
debugPrint(
|
||||
'Warning: Chapter with href ${targetHref.href} not found in spine.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> navigateToFirstTocItemFirstPage() async {
|
||||
navigateToSpineItem(0, 'top');
|
||||
}
|
||||
|
||||
Set<TocEntry> resolveActiveItems() {
|
||||
return bookSession.resolveActiveItems(currentSpineItemIndex);
|
||||
}
|
||||
|
||||
void _showToast(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/pages/epub_reader/mixins/theme_mixin.dart
Normal file
46
lib/pages/epub_reader/mixins/theme_mixin.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
part of '../reader_screen.dart';
|
||||
|
||||
mixin _ThemeMixin on State<ReaderScreen> {
|
||||
// === Borrowed state (provided by _ReaderScreenState fields) ===
|
||||
ReaderRendererController get rendererController;
|
||||
|
||||
ReaderSettings get readerSettings;
|
||||
|
||||
ThemeData? get currentTheme;
|
||||
set currentTheme(ThemeData? v);
|
||||
|
||||
bool get updatingTheme;
|
||||
set updatingTheme(bool v);
|
||||
|
||||
Timer? get themeUpdateDebouncer;
|
||||
set themeUpdateDebouncer(Timer? v);
|
||||
|
||||
EpubTheme getEpubTheme() {
|
||||
return readerSettings.toEpubTheme(context);
|
||||
}
|
||||
|
||||
void updateWebViewThemeWithDebounce() {
|
||||
themeUpdateDebouncer?.cancel();
|
||||
themeUpdateDebouncer = Timer(const Duration(milliseconds: 50), () {
|
||||
updateWebViewTheme();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateWebViewTheme() async {
|
||||
final newTheme = getEpubTheme();
|
||||
final currentWebViewTheme = rendererController.currentTheme;
|
||||
if (currentWebViewTheme != null && currentWebViewTheme == newTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
updatingTheme = true;
|
||||
});
|
||||
|
||||
await rendererController.updateTheme(getEpubTheme());
|
||||
|
||||
setState(() {
|
||||
updatingTheme = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user