Files
MookNote/lib/pages/epub_reader/reader_renderer.dart
2026-07-02 05:22:21 +08:00

521 lines
16 KiB
Dart

import 'dart:io';
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../utils/epub/epub_theme.dart';
import '../../utils/epub/reader_settings.dart';
import '../../utils/epub/epub_webview_handler.dart';
import 'book_session.dart';
import 'reader_webview.dart';
import 'page_turn/page_turn.dart';
class ReaderRendererController {
_ReaderRendererState? _rendererState;
bool get isAttached => _rendererState != null;
EpubTheme? get currentTheme => _rendererState?._currentTheme;
ReaderWebViewController? get webViewController =>
_rendererState?._webViewController;
void _attachState(_ReaderRendererState? state) {
_rendererState = state;
}
Future<void> performPreviousPageTurn() async {
await webViewController?.waitForRender();
await _rendererState?._performPageTurn(false);
}
Future<void> performNextPageTurn() async {
await webViewController?.waitForRender();
await _rendererState?._performPageTurn(true);
}
Future<void> jumpToPage(int pageIndex) async {
await webViewController?.jumpToPage(pageIndex);
}
Future<void> restoreScrollPosition(double ratio) async {
await webViewController?.restoreScrollPosition(ratio);
}
Future<void> jumpToPreviousChapterLastPage() async {
final token1 = await webViewController?.jumpToLastPageOfFrame('prev');
final token2 = await webViewController?.cycleFrames('prev');
final tokens = [token1, token2].whereType<int>().toList();
await webViewController?.waitForEvents(tokens);
}
Future<void> jumpToPreviousChapterFirstPage() async {
final token1 = await webViewController?.jumpToPageFor('prev', 0);
final token2 = await webViewController?.cycleFrames('prev');
final tokens = [token1, token2].whereType<int>().toList();
await webViewController?.waitForEvents(tokens);
}
Future<void> jumpToNextChapter() async {
final token1 = await webViewController?.jumpToPageFor('next', 0);
final token2 = await webViewController?.cycleFrames('next');
final tokens = [token1, token2].whereType<int>().toList();
await webViewController?.waitForEvents(tokens);
}
Future<int?> preloadCurrentChapter(
String url,
List<String> anchors,
String? properties,
) async {
final anchorsParam = anchors.map((a) => '"$a"').join(',');
final anchorsJson = '[$anchorsParam]';
final propertiesList = List<String>.from(properties?.split(' ') ?? []);
final encodedPropertiesList = propertiesList
.map((p) => p.replaceAll(':', '-COLON-'))
.toList();
final propertiesParam = encodedPropertiesList.map((p) => '"$p"').join(',');
final propertiesJson = '[$propertiesParam]';
return await webViewController?.loadFrame(
'curr',
url,
anchorsJson,
propertiesJson,
);
}
Future<int?> preloadNextChapter(
String url,
List<String> anchors,
String? properties,
) async {
final anchorsParam = anchors.map((a) => '"$a"').join(',');
final anchorsJson = '[$anchorsParam]';
final propertiesList = List<String>.from(properties?.split(' ') ?? []);
final encodedPropertiesList = propertiesList
.map((p) => p.replaceAll(':', '-COLON-'))
.toList();
final propertiesParam = encodedPropertiesList.map((p) => '"$p"').join(',');
final propertiesJson = '[$propertiesParam]';
return await webViewController?.loadFrame(
'next',
url,
anchorsJson,
propertiesJson,
);
}
Future<int?> preloadPreviousChapter(
String url,
List<String> anchors,
String? properties,
) async {
final anchorsParam = anchors.map((a) => '"$a"').join(',');
final anchorsJson = '[$anchorsParam]';
final propertiesList = List<String>.from(properties?.split(' ') ?? []);
final encodedPropertiesList = propertiesList
.map((p) => p.replaceAll(':', '-COLON-'))
.toList();
final propertiesParam = encodedPropertiesList.map((p) => '"$p"').join(',');
final propertiesJson = '[$propertiesParam]';
return await webViewController?.loadFrame(
'prev',
url,
anchorsJson,
propertiesJson,
);
}
Future<void> updateTheme(EpubTheme theme) async {
await _rendererState?._updateTheme(theme);
}
Future<void> waitForEvents(List<int> tokens) async {
await webViewController?.waitForEvents(tokens);
}
Future<void> waitForEvent(int token) async {
await webViewController?.waitForEvent(token);
}
}
class ReaderRenderer extends StatefulWidget {
final ReaderRendererController controller;
final BookSession bookSession;
final EpubWebViewHandler webViewHandler;
final String fileHash;
final bool showControls;
final bool isLoading;
final bool Function(bool isNext) canPerformPageTurn;
final Future<void> Function(bool isNext) onPerformPageTurn;
final VoidCallback onToggleControls;
final Future<void> Function() onInitialized;
final Future<void> Function(int totalPages) onPageCountReady;
final ValueChanged<int> onPageChanged;
final ValueChanged<List<String>> onScrollAnchors;
final Function(String imageUrl, Rect rect) onImageLongPress;
final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap;
final Function(String url) onLinkTap;
final bool Function(String url) shouldHandleLinkTap;
final Function(String selectedText, Rect rect, int spineIndex, double scrollRatio, Offset? startHandle, Offset? endHandle, Map<String, dynamic>? selectionInfo)? onTextSelection;
final bool shouldShowWebView;
final EpubTheme initializeTheme;
final String statusBarLeftContent;
final String statusBarRightContent;
/// Controls page-turn animation style. Caller reads from ReaderSettings
/// and passes the value here (avoids Riverpod dependency).
final ReaderPageAnimation pageAnimation;
const ReaderRenderer({
super.key,
required this.controller,
required this.bookSession,
required this.webViewHandler,
required this.fileHash,
required this.showControls,
required this.isLoading,
required this.canPerformPageTurn,
required this.onPerformPageTurn,
required this.onToggleControls,
required this.onInitialized,
required this.onPageCountReady,
required this.onPageChanged,
required this.onScrollAnchors,
required this.onImageLongPress,
required this.onFootnoteTap,
required this.onLinkTap,
required this.shouldHandleLinkTap,
this.onTextSelection,
required this.shouldShowWebView,
required this.initializeTheme,
required this.statusBarLeftContent,
required this.statusBarRightContent,
this.pageAnimation = ReaderPageAnimation.slide,
});
bool get isVertical {
return bookSession.direction == 1;
}
@override
State<ReaderRenderer> createState() => _ReaderRendererState();
}
class _ReaderRendererState extends State<ReaderRenderer>
with TickerProviderStateMixin {
final GlobalKey _webViewKey = GlobalKey();
final ReaderWebViewController _webViewController = ReaderWebViewController();
late final AndroidPageTurnSession _androidPageTurnSession;
late final IOSPageTurnSession _iosPageTurnSession;
late EpubTheme _currentTheme;
late bool _needPageTurnAnimation;
EdgeInsets _addSafeAreaToPadding(EdgeInsets basePadding) {
final safePaddings = MediaQuery.paddingOf(context);
final safeBottomPadding = max(safePaddings.bottom, 32);
return EdgeInsets.fromLTRB(
basePadding.left + safePaddings.left,
basePadding.top + safePaddings.top,
basePadding.right + safePaddings.right,
basePadding.bottom + safeBottomPadding,
);
}
EpubTheme _addSafeAreaToThemePadding(EpubTheme theme) {
final newPadding = _addSafeAreaToPadding(theme.padding);
return theme.copyWith(padding: newPadding);
}
Future<void> _updateTheme(EpubTheme theme) async {
_currentTheme = theme;
await _webViewController.updateTheme(
theme.copyWith(padding: _addSafeAreaToPadding(theme.padding)),
);
}
@override
void initState() {
super.initState();
widget.controller._attachState(this);
_androidPageTurnSession = AndroidPageTurnSession(
vsync: this,
duration: const Duration(milliseconds: 250),
);
_iosPageTurnSession = IOSPageTurnSession();
_currentTheme = widget.initializeTheme;
_needPageTurnAnimation =
widget.pageAnimation != ReaderPageAnimation.none;
}
@override
void didUpdateWidget(covariant ReaderRenderer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pageAnimation != widget.pageAnimation) {
setState(() {
_needPageTurnAnimation =
widget.pageAnimation != ReaderPageAnimation.none;
});
}
}
@override
void dispose() {
widget.controller._attachState(null);
_androidPageTurnSession.dispose();
super.dispose();
}
Future<void> _performPageTurn(bool isNext) async {
if (!widget.canPerformPageTurn(isNext)) return;
if (Platform.isAndroid) {
await _androidPageTurnSession.perform(
webViewController: _webViewController,
needAnimation: _needPageTurnAnimation,
isNext: isNext,
isVertical: widget.isVertical,
onPerformPageTurn: widget.onPerformPageTurn,
setState: setState,
isMounted: () => mounted,
);
} else {
await _iosPageTurnSession.perform(
needAnimation: _needPageTurnAnimation,
isNext: isNext,
isVertical: widget.isVertical,
onPerformPageTurn: widget.onPerformPageTurn,
);
}
}
void _handleTap(TapUpDetails details) {
if (widget.showControls) {
widget.onToggleControls();
} else if (_androidPageTurnSession.isAnimating ||
_iosPageTurnSession.isAnimating) {
_handleTapZone(details.globalPosition.dx, details.globalPosition.dy);
} else {
_webViewController.checkTapElementAt(
details.globalPosition.dx,
details.globalPosition.dy,
);
}
}
void _handleTapZone(double x, double y) {
final width = MediaQuery.of(context).size.width;
if (width <= 0) return;
final ratio = x / width;
if (ratio < 0.3) {
if (widget.showControls) {
widget.onToggleControls();
return;
}
if (widget.isVertical) {
_performPageTurn(true);
} else {
_performPageTurn(false);
}
} else if (ratio > 0.7) {
if (widget.showControls) {
widget.onToggleControls();
return;
}
if (widget.isVertical) {
_performPageTurn(false);
} else {
_performPageTurn(true);
}
} else {
widget.onToggleControls();
}
}
Future<void> _handleHorizontalDragEnd(DragEndDetails details) async {
if (widget.showControls) {
return;
}
final velocity = details.primaryVelocity ?? 0;
if (velocity < -200) {
if (widget.isVertical) {
await _performPageTurn(false);
} else {
await _performPageTurn(true);
}
} else if (velocity > 200) {
if (widget.isVertical) {
await _performPageTurn(true);
} else {
await _performPageTurn(false);
}
}
}
Future<void> _handleLongPressStart(LongPressStartDetails details) async {
await _webViewController.checkLongPressElementAt(
details.localPosition.dx,
details.localPosition.dy,
);
// 编程式选中最接近长按位置的文字(触发 onTextSelection 回调)
await _webViewController.selectWordAt(
details.localPosition.dx,
details.localPosition.dy,
);
}
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: widget.shouldShowWebView ? _handleTap : null,
onHorizontalDragEnd: widget.shouldShowWebView
? _handleHorizontalDragEnd
: null,
onLongPressStart: widget.shouldShowWebView
? _handleLongPressStart
: null,
child: Stack(
fit: StackFit.expand,
children: [_buildBody(), _buildBottomStatusBarOverlay()],
),
),
);
}
Widget _buildBottomStatusBarOverlay() {
Widget buildBadge(
String content,
bool tabular, {
TextOverflow overflow = TextOverflow.clip,
}) {
return Text(
content,
overflow: overflow,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
fontSize: 10,
fontWeight: FontWeight.w500,
fontFeatures: tabular ? const [FontFeature.tabularFigures()] : null,
shadows: [
Shadow(
color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.5),
blurRadius: 1.0,
offset: Offset.zero,
),
],
),
);
}
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.only(left: 32, right: 32, bottom: 8),
constraints: const BoxConstraints(minHeight: 32, maxHeight: 32),
child: AnimatedOpacity(
duration: (widget.isLoading || !widget.shouldShowWebView)
? Duration.zero
: const Duration(milliseconds: 250),
curve: Curves.easeOut,
opacity: (widget.isLoading || !widget.shouldShowWebView) ? 0.0 : 1.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: buildBadge(
widget.statusBarLeftContent,
false,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
buildBadge(widget.statusBarRightContent, true),
],
),
),
),
);
}
Widget _buildBody() {
return Platform.isAndroid
? _androidPageTurnSession.buildAnimatedContainer(
context,
_buildWebView(),
_buildScreenshotContainer,
)
: _iosPageTurnSession.buildAnimatedContainer(context, _buildWebView());
}
Widget _buildContentWrapper(Widget child) {
return Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withValues(
alpha: _currentTheme.isDark ? 0.3 : 0.15,
),
blurRadius: 25,
offset: Offset.zero,
),
],
color: _currentTheme.surfaceColor,
),
child: Container(alignment: AlignmentGeometry.center, child: child),
);
}
Widget _buildWebView() {
return _buildContentWrapper(
ReaderWebView(
key: _webViewKey,
bookSession: widget.bookSession,
webViewHandler: widget.webViewHandler,
fileHash: widget.fileHash,
initializeTheme: _addSafeAreaToThemePadding(widget.initializeTheme),
isLoading: widget.isLoading,
controller: _webViewController,
callbacks: ReaderWebViewCallbacks(
onInitialized: () async {
await widget.onInitialized();
},
onPageCountReady: (totalPages) async {
await widget.onPageCountReady(totalPages);
},
onPageChanged: widget.onPageChanged,
onScrollAnchors: widget.onScrollAnchors,
onImageLongPress: widget.onImageLongPress,
onTap: _handleTapZone,
onFootnoteTap: widget.onFootnoteTap,
onLinkTap: widget.onLinkTap,
shouldHandleLinkTap: widget.shouldHandleLinkTap,
onTextSelection: widget.onTextSelection,
),
shouldShowWebView: widget.shouldShowWebView,
coverRelativePath: widget.bookSession.book['cover_path'] as String?,
direction: widget.bookSession.direction,
),
);
}
Widget _buildScreenshotContainer(ui.Image? screenshot) {
if (screenshot == null) {
return _buildContentWrapper(Container(color: _currentTheme.surfaceColor));
}
return _buildContentWrapper(RawImage(image: screenshot, fit: BoxFit.cover));
}
}