import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../../utils/epub/epub_theme.dart'; import 'book_session.dart'; import '../../utils/epub/epub_webview_handler.dart'; import '../../utils/epub/reader_scripts.dart'; import '../../utils/epub/web/webview_bridge.dart'; import '../../utils/epub/web/reader_api.dart'; /// Controller for ReaderWebView that provides methods to control the WebView class ReaderWebViewController { _ReaderWebViewState? _webViewState; bool get isAttached => _webViewState != null; void _attachState(_ReaderWebViewState? state) { _webViewState = state; } // JavaScript wrapper methods Future jumpToLastPageOfFrame(String frame) async { return await _webViewState?._jumpToLastPageOfFrame(frame); } Future cycleFrames(String direction) async { return await _webViewState?._cycleFrames(direction); } Future jumpToPageFor(String frame, int pageIndex) async { return await _webViewState?._jumpToPageFor(frame, pageIndex); } Future loadFrame( String frame, String url, String anchors, String properties, ) async { return await _webViewState?._loadFrame(frame, url, anchors, properties); } Future jumpToPage(int pageIndex) async { await _webViewState?._jumpToPage(pageIndex); } Future restoreScrollPosition(double ratio) async { await _webViewState?._restoreScrollPosition(ratio); } Future checkLongPressElementAt(double x, double y) async { await _webViewState?._checkLongPressElementAt(x, y); } Future checkTapElementAt(double x, double y) async { await _webViewState?._checkTapElementAt(x, y); } Future takeScreenshot() async { return await _webViewState?._takeScreenshot(); } Future waitForRender() async { await _webViewState?._waitForRender(); } Future updateTheme(EpubTheme theme) async { await _webViewState?._updateTheme(theme); } Future waitForEvent(int token, [int timeoutMs = 10000]) async { await _webViewState?._bridge.waitForEvent(token, timeoutMs); } Future waitForEvents(List tokens, [int timeoutMs = 10000]) async { await _webViewState?._bridge.waitForEvents(tokens, timeoutMs); } Future runJavaScriptReturningResult(String js) async { return await _webViewState?._controller?.evaluateJavascript(source: js); } } final InAppWebViewSettings defaultSettings = InAppWebViewSettings( disableContextMenu: true, disableLongPressContextMenuOnLinks: true, selectionGranularity: SelectionGranularity.CHARACTER, transparentBackground: true, allowFileAccessFromFileURLs: true, allowUniversalAccessFromFileURLs: true, useShouldInterceptRequest: true, useOnLoadResource: false, useShouldOverrideUrlLoading: true, javaScriptEnabled: true, disableHorizontalScroll: true, disableVerticalScroll: true, supportZoom: false, useHybridComposition: false, resourceCustomSchemes: [EpubWebViewHandler.virtualScheme], verticalScrollBarEnabled: false, horizontalScrollBarEnabled: false, overScrollMode: OverScrollMode.NEVER, ); /// Callbacks for WebView events class ReaderWebViewCallbacks { final Function() onInitialized; final Function(int totalPages) onPageCountReady; final Function(int pageIndex) onPageChanged; final Function(List anchors) onScrollAnchors; final Function(String imageUrl, Rect rect) onImageLongPress; final Function(double x, double y) onTap; final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap; final Function(String url) onLinkTap; final bool Function(String url) shouldHandleLinkTap; const ReaderWebViewCallbacks({ required this.onInitialized, required this.onPageCountReady, required this.onPageChanged, required this.onScrollAnchors, required this.onImageLongPress, required this.onTap, required this.onFootnoteTap, required this.onLinkTap, required this.shouldHandleLinkTap, }); } /// WebView widget for reading EPUB content class ReaderWebView extends StatefulWidget { final BookSession bookSession; final EpubWebViewHandler webViewHandler; final String fileHash; final ReaderWebViewCallbacks callbacks; final EpubTheme initializeTheme; final bool isLoading; final ReaderWebViewController controller; final VoidCallback? onWebViewCreated; final bool shouldShowWebView; final String? coverRelativePath; final int direction; const ReaderWebView({ super.key, required this.bookSession, required this.webViewHandler, required this.fileHash, required this.callbacks, required this.initializeTheme, required this.isLoading, required this.controller, this.onWebViewCreated, required this.shouldShowWebView, this.coverRelativePath, required this.direction, }); @override State createState() => _ReaderWebViewState(); } class _ReaderWebViewState extends State { final GlobalKey _repaintKey = GlobalKey(); InAppWebViewController? _controller; HeadlessInAppWebView? _headlessWebView; bool _isHeadlessInitialized = false; bool _isSubsequentLoad = false; late EpubTheme _currentTheme; final WebViewBridge _bridge = WebViewBridge(); late final ReaderApi _api = ReaderApi(_bridge); @override void initState() { super.initState(); _currentTheme = widget.initializeTheme; widget.controller._attachState(this); } @override void didUpdateWidget(covariant ReaderWebView oldWidget) { super.didUpdateWidget(oldWidget); if (!oldWidget.isLoading && widget.isLoading) { setState(() { _isSubsequentLoad = true; }); } } void _initHeadlessWebViewIfNeeded(double width, double height) { if (_isHeadlessInitialized) return; _headlessWebView = HeadlessInAppWebView( initialData: _generateInitialData(width, height), initialSettings: defaultSettings, shouldInterceptRequest: _shouldInterceptRequest, onLoadResourceWithCustomScheme: _onLoadResourceWithCustomScheme, shouldOverrideUrlLoading: _shouldOverrideUrlLoading, onWebViewCreated: _onWebViewCreated, onLoadStop: _onLoadStop, ); _headlessWebView?.run(); _isHeadlessInitialized = true; } Future _waitForWebviewRender() async { if (_controller == null) return; await _api.waitForRender(); } Future _waitForRender() async { await _waitForWebviewRender(); } Future _jumpToLastPageOfFrame(String frame) => _api.jumpToLastPageOfFrame(frame); Future _cycleFrames(String direction) => _api.cycleFrames(direction); Future _jumpToPageFor(String frame, int pageIndex) => _api.jumpToPageFor(frame, pageIndex); Future _loadFrame( String frame, String url, String anchors, String properties, ) => _api.loadFrame(frame, url, anchors, properties); Future _jumpToPage(int pageIndex) => _api.jumpToPage(pageIndex); Future _restoreScrollPosition(double ratio) => _api.restoreScrollPosition(ratio); Future _checkLongPressElementAt(double x, double y) => _api.checkLongPressElementAt(x, y); Future _checkTapElementAt(double x, double y) => _api.checkTapElementAt(x, y); InAppWebViewInitialData _generateInitialData(double width, double height) { return InAppWebViewInitialData( data: generateSkeletonHtml( width, height, _currentTheme, widget.direction, ), baseUrl: WebUri(EpubWebViewHandler.getBaseUrl()), ); } Future _shouldInterceptRequest( InAppWebViewController controller, WebResourceRequest request, ) async { return await widget.webViewHandler.handleRequest( epubPath: widget.bookSession.book['file_path'] as String, fileHash: widget.fileHash, requestUrl: request.url, ); } Future _onLoadResourceWithCustomScheme( InAppWebViewController controller, WebResourceRequest request, ) async { return await widget.webViewHandler.handleRequestWithCustomScheme( epubPath: widget.bookSession.book['file_path'] as String, fileHash: widget.fileHash, requestUrl: request.url, ); } Future _shouldOverrideUrlLoading( InAppWebViewController controller, NavigationAction navigationAction, ) async { final uri = navigationAction.request.url!; if (uri.scheme == 'data') { return NavigationActionPolicy.ALLOW; } if (EpubWebViewHandler.isEpubRequest(uri)) { return NavigationActionPolicy.ALLOW; } return NavigationActionPolicy.CANCEL; } void _onWebViewCreated(InAppWebViewController controller) { _controller = controller; _bridge.attach(controller); _setupJavaScriptHandlers(controller); widget.onWebViewCreated?.call(); } void _onLoadStop(InAppWebViewController controller, WebUri? url) { widget.callbacks.onInitialized(); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth - _currentTheme.padding.horizontal; final height = constraints.maxHeight - _currentTheme.padding.vertical; _initHeadlessWebViewIfNeeded(width, height); return Stack( children: [ RepaintBoundary( key: _repaintKey, child: AbsorbPointer( child: widget.shouldShowWebView ? InAppWebView( headlessWebView: _headlessWebView, initialData: _generateInitialData(width, height), initialSettings: defaultSettings, shouldInterceptRequest: _shouldInterceptRequest, onLoadResourceWithCustomScheme: _onLoadResourceWithCustomScheme, shouldOverrideUrlLoading: _shouldOverrideUrlLoading, onWebViewCreated: _onWebViewCreated, onLoadStop: _onLoadStop, ) : Container(color: _currentTheme.surfaceColor), ), ), Positioned.fill( child: IgnorePointer( ignoring: !widget.isLoading && widget.shouldShowWebView, child: AnimatedOpacity( duration: (widget.isLoading || !widget.shouldShowWebView) ? Duration.zero : const Duration(milliseconds: 250), curve: Curves.easeOut, opacity: (widget.isLoading || !widget.shouldShowWebView) ? 1.0 : 0.0, child: Container( color: _currentTheme.surfaceColor, child: _isSubsequentLoad ? null : Center( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.4, maxWidth: MediaQuery.of(context).size.width * 0.6, ), child: _buildCoverPlaceholder(), ), ), ), ), ), ), ], ); }, ); } /// Simple cover placeholder — loads image from file if available, /// otherwise shows a book icon. Widget _buildCoverPlaceholder() { final path = widget.coverRelativePath; if (path != null && path.isNotEmpty) { final file = File(path); if (file.existsSync()) { return ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.file(file, fit: BoxFit.cover), ); } } return Container( decoration: BoxDecoration( color: _currentTheme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Icon( Icons.book_outlined, size: 64, color: _currentTheme.colorScheme.onSurfaceVariant, ), ); } void _setupJavaScriptHandlers(InAppWebViewController controller) { controller.addJavaScriptHandler( handlerName: 'onPageCountReady', callback: (args) async { if (args.isNotEmpty && args[0] is int) { widget.callbacks.onPageCountReady(args[0] as int); } }, ); controller.addJavaScriptHandler( handlerName: 'onPageChanged', callback: (args) { if (args.isNotEmpty && args[0] is int) { widget.callbacks.onPageChanged(args[0] as int); } }, ); controller.addJavaScriptHandler( handlerName: 'onScrollAnchors', callback: (args) { if (args.isEmpty) return; final List anchors = List.from(args[0] as List); widget.callbacks.onScrollAnchors(anchors); }, ); controller.addJavaScriptHandler( handlerName: 'onTap', callback: (args) { if (args.isEmpty) return; final x = (args[0] as num).toDouble(); final y = (args[1] as num).toDouble(); widget.callbacks.onTap(x, y); }, ); controller.addJavaScriptHandler( handlerName: 'onFootnoteTap', callback: (args) { if (args.isEmpty) return; final innerHtml = args[0] as String; final rect = Rect.fromLTWH( (args[1] as num).toDouble(), (args[2] as num).toDouble(), (args[3] as num).toDouble(), (args[4] as num).toDouble(), ); final baseUrl = args.length > 5 && args[5] is String ? args[5] as String : ''; widget.callbacks.onFootnoteTap(innerHtml, rect, baseUrl); }, ); controller.addJavaScriptHandler( handlerName: 'onLinkTap', callback: (args) { if (args.isEmpty) return; final url = args[0] as String; final x = (args[1] as num).toDouble(); final y = (args[2] as num).toDouble(); if (widget.callbacks.shouldHandleLinkTap(url)) { widget.callbacks.onLinkTap(url); } else { widget.callbacks.onTap(x, y); } }, ); controller.addJavaScriptHandler( handlerName: 'onImageLongPress', callback: (args) { if (args.length >= 5 && args[0] is String) { final imageUrl = args[0] as String; final rect = Rect.fromLTWH( (args[1] as num).toDouble(), (args[2] as num).toDouble(), (args[3] as num).toDouble(), (args[4] as num).toDouble(), ); widget.callbacks.onImageLongPress(imageUrl, rect); } }, ); controller.addJavaScriptHandler( handlerName: 'onViewportResize', callback: (args) { _updateTheme(_currentTheme); }, ); controller.addJavaScriptHandler( handlerName: 'onEventFinished', callback: (args) { if (args.isNotEmpty) { _bridge.resolveToken(args[0] as int); } }, ); } Future _takeScreenshot() async { if (Platform.isAndroid) { // for Android final BuildContext? context = _repaintKey.currentContext; if (context == null) return null; final RenderRepaintBoundary? boundary = context.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return null; ui.Image image = await boundary.toImage(pixelRatio: 3.0); return image; } else { throw UnimplementedError( 'Do not use screenshot on iOS, it may cause performance issues.', ); } } Future _updateTheme(EpubTheme theme) async { if (_controller == null) return; final width = MediaQuery.of(context).size.width - theme.padding.horizontal; final height = MediaQuery.of(context).size.height - theme.padding.vertical; _currentTheme = theme; await _api.updateTheme(width, height, theme.toThemeMap()); } }