原生epub阅读

This commit is contained in:
DelLevin-Home
2026-06-27 14:06:44 +08:00
parent 5acbc3a602
commit d54e33e4cf
177 changed files with 6500 additions and 93933 deletions

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

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

View 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')),
);
}
}
}
}
}

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

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

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

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