import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/helpers/l10n_helpers.dart'; import 'package:worldhopper/models/enhanced_metadata.dart'; import 'package:worldhopper/models/entry.dart'; import 'package:worldhopper/models/server.dart'; import 'package:worldhopper/models/publication.dart'; import 'package:worldhopper/models/stream_link.dart'; import 'package:worldhopper/models/reading_progress.dart'; import 'package:worldhopper/providers/sync_progress_provider.dart'; import 'package:worldhopper/providers/cover_page_provider.dart'; import 'package:worldhopper/providers/page_dimensions_provider.dart'; import 'package:worldhopper/providers/publication_cache_provider.dart'; import 'package:worldhopper/providers/reading_progress_provider.dart'; import 'package:worldhopper/providers/server_provider.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; import 'package:worldhopper/providers/server_software_provider.dart'; import 'package:worldhopper/providers/filter_quality_provider.dart'; import 'package:worldhopper/providers/precache_pages_provider.dart'; import 'package:worldhopper/providers/reading_mode_provider.dart'; import 'package:worldhopper/providers/eink_mode_provider.dart'; import 'package:worldhopper/providers/two_page_mode_provider.dart'; import 'package:worldhopper/screens/reader/page_grouping.dart'; import 'package:worldhopper/screens/reader/two_page_reader.dart'; import 'package:worldhopper/services/auth_service.dart'; import 'package:worldhopper/services/server_software/server_software_service.dart'; import 'package:uuid/uuid.dart'; import 'package:worldhopper/widgets/next_in_series_overlay.dart'; /// Main reader screen for page-by-page reading class ReaderScreen extends ConsumerStatefulWidget { final Entry entry; final String serverId; final int initialPage; final String? feedUrl; final String? localContentPath; const ReaderScreen({ super.key, required this.entry, required this.serverId, this.initialPage = 0, this.feedUrl, this.localContentPath, }); @override ConsumerState createState() => _ReaderScreenState(); } class _ReaderScreenState extends ConsumerState { late PageController _pageController; late int _currentPage; bool _showControls = true; bool _isInitialized = false; bool _reachedEndThisSession = false; ScrollController? _scrollController; bool _needsVerticalScrollToPage = false; ReadingMode? _previousMode; // Two-page spread state PageController? _twoPageController; List _spreads = []; bool _wasTwoPage = false; // E-ink swipe tracking double? _einkSwipeStartX; // Cached file extension for local content (avoids probing 5 extensions per page) String? _localFileExtension; bool _localExtensionResolved = false; @override void initState() { super.initState(); _currentPage = widget.initialPage; _pageController = PageController(initialPage: widget.initialPage); // Check if already on the last page (e.g. resuming a completed chapter) final pageCount = widget.entry.streamLink?.pageCount ?? 0; if (pageCount > 0 && widget.initialPage >= pageCount - 1) { _reachedEndThisSession = true; } // Enter fullscreen immersive mode SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); // Keep screen awake while reading WakelockPlus.enable(); } // Provider refs captured in deactivate() for use in dispose(). // Riverpod invalidates `ref` after deactivate() completes, so reading // providers in dispose() throws. We snapshot the values we need here. bool _capturedIsOnline = false; Future? _capturedServerFuture; ServerSoftwareService? _capturedSoftwareService; ReadingProgressNotifier? _capturedProgressNotifier; Future? _capturedProgressFuture; Future? _capturedCachedPubFuture; @override void deactivate() { // Capture all provider references while ref is still valid. _capturedIsOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; _capturedServerFuture = ref.read(serverProvider(widget.serverId).future); _capturedSoftwareService = ref.read(serverSoftwareServiceProvider); _capturedProgressNotifier = ref.read(readingProgressNotifierProvider.notifier); _capturedProgressFuture = ref.read(readingProgressProvider(ProgressKey( publicationId: widget.entry.id, serverId: widget.serverId, )).future); _capturedCachedPubFuture = ref.read( cachedPublicationProvider(widget.serverId, widget.entry.id).future); super.deactivate(); } @override void dispose() { // If the user finished the chapter, remove from recently read; // otherwise save progress one final time before disposing. // Guard against deactivate() not having been called (captured fields null). if (_reachedEndThisSession) { if (_capturedProgressNotifier != null && _capturedServerFuture != null && _capturedSoftwareService != null) { _deleteCompletedProgressDetached( isOnline: _capturedIsOnline, serverFuture: _capturedServerFuture!, softwareService: _capturedSoftwareService!, progressNotifier: _capturedProgressNotifier!, ); } } else { if (_capturedProgressNotifier != null && _capturedProgressFuture != null && _capturedCachedPubFuture != null) { _saveProgressDetached( _currentPage, progressFuture: _capturedProgressFuture!, cachedPubFuture: _capturedCachedPubFuture!, progressNotifier: _capturedProgressNotifier!, ); } } _pageController.dispose(); _twoPageController?.dispose(); _scrollController?.dispose(); // Restore system UI SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // Allow screen to sleep again WakelockPlus.disable(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); if (!widget.entry.hasStreamLink) { return Scaffold( appBar: AppBar( title: Text(l10n.readerError), ), body: Center( child: Text(l10n.readerNoStreamLink), ), ); } final streamLink = widget.entry.streamLink!; final serverAsync = ref.watch(serverProvider(widget.serverId)); // Initialize or update reading progress if (!_isInitialized) { _isInitialized = true; WidgetsBinding.instance.addPostFrameCallback((_) { _initializeProgress(); }); } return PopScope( canPop: true, onPopInvokedWithResult: (bool didPop, Object? result) { if (didPop) { if (_reachedEndThisSession) { _deleteCompletedProgress(); } else { _saveProgress(_currentPage); } } }, child: Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // Page viewer serverAsync.when( data: (server) { if (server == null) { return Center( child: Text( l10n.readerServerNotFound, style: const TextStyle(color: Colors.white), ), ); } final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null ? ref.watch( seriesReadingModeNotifierProvider(widget.feedUrl!)) : null; final readingMode = seriesOverride ?? globalMode; // Determine if two-page mode should activate final globalTwoPage = ref.watch(twoPageModeNotifierProvider); final seriesToPageOverride = widget.feedUrl != null ? ref.watch( seriesToPageModeNotifierProvider(widget.feedUrl!)) : null; final twoPageMode = seriesToPageOverride ?? globalTwoPage; final screenWidth = MediaQuery.of(context).size.width; final isPaged = readingMode != ReadingMode.verticalContinuous; final useTwoPage = isPaged && twoPageMode == TwoPageMode.auto && screenWidth >= 768; // Handle mode switching mid-read if (_previousMode != null && _previousMode != readingMode) { if (readingMode == ReadingMode.verticalContinuous) { // Switching to vertical: create new scroll controller _scrollController?.dispose(); _scrollController = null; } else { // Switching to paged: jump to current page after frame WidgetsBinding.instance.addPostFrameCallback((_) { if (_pageController.hasClients) { _pageController.jumpToPage(_currentPage); } }); } } _previousMode = readingMode; // Handle transition between single-page and two-page modes if (_wasTwoPage && !useTwoPage) { // Transitioning from two-page to single-page _wasTwoPage = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (_pageController.hasClients) { _pageController.jumpToPage(_currentPage); } }); } else if (!_wasTwoPage && useTwoPage) { // Transitioning from single-page to two-page _wasTwoPage = true; } if (useTwoPage) { final isRtl = readingMode == ReadingMode.rtl; final globalCover = ref.watch(coverPageNotifierProvider); final seriesCoverOverride = widget.feedUrl != null ? ref.watch( seriesCoverPageNotifierProvider(widget.feedUrl!)) : null; final firstPageIsCover = seriesCoverOverride ?? globalCover; return _buildTwoPageReader(streamLink, server, rtl: isRtl, firstPageIsCover: firstPageIsCover); } return switch (readingMode) { ReadingMode.ltr => _buildPagedReader(streamLink, server, reverse: false), ReadingMode.rtl => _buildPagedReader(streamLink, server, reverse: true), ReadingMode.verticalContinuous => _buildVerticalReader(streamLink, server), }; }, loading: () => const Center( child: CircularProgressIndicator(color: Colors.white), ), error: (error, stack) => Center( child: Text( l10n.readerErrorLoadingServer(error.toString()), style: const TextStyle(color: Colors.white), ), ), ), // Top controls if (_showControls) Positioned( top: 0, left: 0, right: 0, child: _buildTopBar(context, streamLink), ), // Bottom controls if (_showControls) Positioned( bottom: 0, left: 0, right: 0, child: _buildBottomBar(context, streamLink), ), // Next in series overlay NextInSeriesOverlay( serverId: widget.serverId, feedUrl: widget.feedUrl, entry: widget.entry, isVisible: _reachedEndThisSession, einkMode: ref.watch(einkModeNotifierProvider), isDownloadedContent: widget.localContentPath != null, onBeforeNavigate: _deleteCompletedProgress, ), ], ), ), ); } Widget _buildPagedReader(StreamLink streamLink, dynamic server, {required bool reverse}) { final filterQuality = ref.watch(filterQualityNotifierProvider); final einkMode = ref.watch(einkModeNotifierProvider); Widget gallery = GestureDetector( onTap: () { setState(() { _showControls = !_showControls; }); }, child: PhotoViewGallery.builder( scrollPhysics: einkMode ? const NeverScrollableScrollPhysics() : const BouncingScrollPhysics(), reverse: reverse, builder: (context, index) { return PhotoViewGalleryPageOptions( imageProvider: _getPageImageProvider(index, streamLink, server), filterQuality: filterQuality, minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 3, heroAttributes: PhotoViewHeroAttributes( tag: 'page-$index', ), ); }, itemCount: streamLink.pageCount, loadingBuilder: (context, event) => Center( child: CircularProgressIndicator( value: event == null ? 0 : event.cumulativeBytesLoaded / (event.expectedTotalBytes ?? 1), color: Colors.white, ), ), backgroundDecoration: const BoxDecoration( color: Colors.black, ), pageController: _pageController, onPageChanged: (index) { setState(() { _currentPage = index; if (index >= streamLink.pageCount - 1) { _reachedEndThisSession = true; } }); _saveProgress(index); _reportPageToServer(index); _precacheUpcomingPages(index); }, ), ); if (einkMode) { gallery = _wrapWithEinkSwipe( child: gallery, controller: _pageController, itemCount: streamLink.pageCount, reverse: reverse, ); } return gallery; } Widget _buildTwoPageReader(StreamLink streamLink, dynamic server, {required bool rtl, required bool firstPageIsCover}) { final filterQuality = ref.watch(filterQualityNotifierProvider); final dimensions = ref.watch(pageDimensionsNotifierProvider(widget.entry.id)); // Recompute spreads whenever dimensions change _spreads = computePageSpreads( pageCount: streamLink.pageCount, dimensions: dimensions, rtl: rtl, firstPageIsCover: firstPageIsCover, ); // Find the spread index for the current page final currentSpreadIndex = spreadIndexForPage(_spreads, _currentPage); // Create or update the two-page controller if (_twoPageController == null || !_twoPageController!.hasClients) { _twoPageController?.dispose(); _twoPageController = PageController(initialPage: currentSpreadIndex); } final einkMode = ref.watch(einkModeNotifierProvider); Widget reader = TwoPageReader( spreads: _spreads, pageController: _twoPageController!, getImageProvider: (index) => _getPageImageProvider(index, streamLink, server), filterQuality: filterQuality, reverse: rtl, einkMode: einkMode, onTap: () { setState(() { _showControls = !_showControls; }); }, onPageChanged: (spreadIndex) { if (spreadIndex < 0 || spreadIndex >= _spreads.length) return; final primaryPage = primaryPageForSpread(_spreads, spreadIndex); final lastPage = lastPageInSpread(_spreads, spreadIndex); setState(() { _currentPage = primaryPage; if (lastPage >= streamLink.pageCount - 1) { _reachedEndThisSession = true; } }); _saveProgress(primaryPage); _reportPageToServer(lastPage); _precacheUpcomingPages(lastPage); }, ); if (einkMode) { reader = _wrapWithEinkSwipe( child: reader, controller: _twoPageController!, itemCount: _spreads.length, reverse: rtl, ); } return reader; } Widget _buildVerticalReader(StreamLink streamLink, dynamic server) { final filterQuality = ref.watch(filterQualityNotifierProvider); // Lazily initialize scroll controller if (_scrollController == null) { _scrollController = ScrollController(); if (_currentPage > 0) { _needsVerticalScrollToPage = true; } } // Restore scroll position to the saved page on resume if (_needsVerticalScrollToPage) { _needsVerticalScrollToPage = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController != null && _scrollController!.hasClients && _scrollController!.position.maxScrollExtent > 0) { final pageCount = streamLink.pageCount; if (pageCount <= 1) return; final targetOffset = (_currentPage / (pageCount - 1)) * _scrollController!.position.maxScrollExtent; _scrollController!.jumpTo( targetOffset.clamp( 0.0, _scrollController!.position.maxScrollExtent), ); } }); } return GestureDetector( onTap: () { setState(() { _showControls = !_showControls; }); }, child: NotificationListener( onNotification: (notification) { if (notification is ScrollUpdateNotification && notification.metrics.maxScrollExtent > 0) { final scrollOffset = notification.metrics.pixels; final maxExtent = notification.metrics.maxScrollExtent; final estimatedPage = (scrollOffset / maxExtent * (streamLink.pageCount - 1)).round(); final clampedPage = estimatedPage.clamp(0, streamLink.pageCount - 1); if (clampedPage != _currentPage) { setState(() { _currentPage = clampedPage; if (clampedPage >= streamLink.pageCount - 1) { _reachedEndThisSession = true; } }); _saveProgress(clampedPage); _reportPageToServer(clampedPage); } } return false; }, child: ListView.builder( controller: _scrollController, itemCount: streamLink.pageCount, itemBuilder: (context, index) { return InteractiveViewer( minScale: 1.0, maxScale: 3.0, child: _buildPageImage(index, streamLink, server, filterQuality), ); }, ), ), ); } /// Wraps [child] with a [Listener] that detects horizontal swipes and calls /// [jumpToPage] on [controller], giving instant page turns with no animation. Widget _wrapWithEinkSwipe({ required Widget child, required PageController controller, required int itemCount, required bool reverse, }) { return Listener( onPointerDown: (e) => _einkSwipeStartX = e.position.dx, onPointerUp: (e) { if (_einkSwipeStartX == null) return; final dx = e.position.dx - _einkSwipeStartX!; _einkSwipeStartX = null; if (dx.abs() < 50) return; // below swipe threshold // Swipe left (dx < 0) → next page, swipe right (dx > 0) → previous final direction = dx < 0 ? 1 : -1; final effectiveDirection = reverse ? -direction : direction; final currentIdx = controller.page?.round() ?? 0; final target = currentIdx + effectiveDirection; if (target >= 0 && target < itemCount) { controller.jumpToPage(target); } }, onPointerCancel: (_) => _einkSwipeStartX = null, child: child, ); } Widget _buildTopBar(BuildContext context, StreamLink streamLink) { return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.7), Colors.transparent, ], ), ), padding: EdgeInsets.only( top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 16, ), child: Consumer( builder: (context, ref, _) { final l10n = AppLocalizations.of(context); // Watch connectivity state final connectivityState = ref.watch(connectivityStateProvider); // Check if offline (default to online while loading) final isOffline = connectivityState.whenOrNull( data: (isOnline) => !isOnline, ) ?? false; return Row( children: [ IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.of(context).pop(), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( widget.entry.title, style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (widget.entry.authors.isNotEmpty) Text( widget.entry.authors.join(', '), style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), // Offline indicator if (isOffline) Padding( padding: const EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Colors.redAccent, semanticLabel: l10n.offlineSemanticLabel, ), ), // Settings menu _buildSettingsMenu(ref), ], ); }, ), ); } void _setReadingMode(WidgetRef ref, ReadingMode mode) { if (widget.feedUrl != null) { ref .read(seriesReadingModeNotifierProvider(widget.feedUrl!).notifier) .setReadingMode(mode); } else { ref.read(readingModeNotifierProvider.notifier).setReadingMode(mode); } } void _setTwoPageMode(WidgetRef ref, TwoPageMode mode) { if (widget.feedUrl != null) { ref .read(seriesToPageModeNotifierProvider(widget.feedUrl!).notifier) .setTwoPageMode(mode); } else { ref.read(twoPageModeNotifierProvider.notifier).setTwoPageMode(mode); } } void _setCoverPage(WidgetRef ref, bool value) { if (widget.feedUrl != null) { ref .read(seriesCoverPageNotifierProvider(widget.feedUrl!).notifier) .setCoverPage(value); } else { ref.read(coverPageNotifierProvider.notifier).setCoverPage(value); } } Widget _buildSettingsMenu(WidgetRef ref) { final l10n = AppLocalizations.of(context); final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null ? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!)) : null; final readingMode = seriesOverride ?? globalMode; final modeShortLabel = readingModeShortLabel(l10n, readingMode); final globalTwoPage = ref.watch(twoPageModeNotifierProvider); final seriesToPageOverride = widget.feedUrl != null ? ref.watch(seriesToPageModeNotifierProvider(widget.feedUrl!)) : null; final twoPageMode = seriesToPageOverride ?? globalTwoPage; final isPaged = readingMode != ReadingMode.verticalContinuous; final globalCover = ref.watch(coverPageNotifierProvider); final seriesCoverOverride = widget.feedUrl != null ? ref.watch(seriesCoverPageNotifierProvider(widget.feedUrl!)) : null; final firstPageIsCover = seriesCoverOverride ?? globalCover; return MenuAnchor( builder: (context, controller, child) { return IconButton( icon: const Icon(Icons.settings, color: Colors.white), onPressed: () { if (controller.isOpen) { controller.close(); } else { controller.open(); } }, ); }, menuChildren: [ SubmenuButton( leadingIcon: const Icon(Icons.chrome_reader_mode_outlined), trailingIcon: const Icon(Icons.chevron_right, size: 18), menuChildren: [ MenuItemButton( leadingIcon: const Icon(Icons.arrow_forward), trailingIcon: readingMode == ReadingMode.ltr ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.ltr), child: Text(l10n.readingModeLtr), ), MenuItemButton( leadingIcon: const Icon(Icons.arrow_back), trailingIcon: readingMode == ReadingMode.rtl ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.rtl), child: Text(l10n.readingModeRtl), ), MenuItemButton( leadingIcon: const Icon(Icons.swap_vert), trailingIcon: readingMode == ReadingMode.verticalContinuous ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.verticalContinuous), child: Text(l10n.readingModeVertical), ), ], child: Text(l10n.readerReadingDirectionWithMode(modeShortLabel)), ), if (isPaged) MenuItemButton( leadingIcon: const Icon(Icons.auto_stories), trailingIcon: twoPageMode == TwoPageMode.auto ? const Icon(Icons.check, size: 18) : null, onPressed: () { final newMode = twoPageMode == TwoPageMode.auto ? TwoPageMode.off : TwoPageMode.auto; _setTwoPageMode(ref, newMode); }, child: Text(l10n.readersTwoPageSpread), ), if (isPaged) MenuItemButton( leadingIcon: const Icon(Icons.looks_one), trailingIcon: firstPageIsCover ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setCoverPage(ref, !firstPageIsCover), child: Text(l10n.readersFirstPageIsCover), ), ], ); } Widget _buildBottomBar(BuildContext context, StreamLink streamLink) { final l10n = AppLocalizations.of(context); final einkMode = ref.watch(einkModeNotifierProvider); final progress = (_currentPage + 1) / streamLink.pageCount; final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null ? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!)) : null; final readingMode = seriesOverride ?? globalMode; final isVertical = readingMode == ReadingMode.verticalContinuous; final isRtl = readingMode == ReadingMode.rtl; // Determine if we're in two-page mode final globalTwoPage = ref.watch(twoPageModeNotifierProvider); final seriesToPageOverride = widget.feedUrl != null ? ref.watch(seriesToPageModeNotifierProvider(widget.feedUrl!)) : null; final twoPageMode = seriesToPageOverride ?? globalTwoPage; final screenWidth = MediaQuery.of(context).size.width; final isPaged = readingMode != ReadingMode.verticalContinuous; final isTwoPage = isPaged && twoPageMode == TwoPageMode.auto && screenWidth >= 768 && _spreads.isNotEmpty; // Build page indicator text String pageIndicator; if (isTwoPage) { final spreadIdx = spreadIndexForPage(_spreads, _currentPage); final spread = _spreads[spreadIdx]; if (spread.isPair) { final lo = spread.primaryPage + 1; final hi = spread.lastPage + 1; pageIndicator = l10n.readerPagesOf(lo, hi, streamLink.pageCount); } else { pageIndicator = l10n.readerPageOf(_currentPage + 1, streamLink.pageCount); } } else { pageIndicator = l10n.readerPageOf(_currentPage + 1, streamLink.pageCount); } // Select active controller for navigation buttons final activeController = isTwoPage && _twoPageController != null ? _twoPageController! : _pageController; final totalItems = isTwoPage ? _spreads.length : streamLink.pageCount; final currentIdx = isTwoPage ? spreadIndexForPage(_spreads, _currentPage) : _currentPage; return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withValues(alpha: 0.7), Colors.transparent, ], ), ), padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom + 8, left: 16, right: 16, top: 16, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Page indicator Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( pageIndicator, style: const TextStyle( color: Colors.white, fontSize: 14, ), ), Text( '${(progress * 100).toInt()}%', style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 8), // Progress bar LinearProgressIndicator( value: progress, backgroundColor: Colors.white.withValues(alpha: 0.3), valueColor: const AlwaysStoppedAnimation(Colors.white), minHeight: 4, ), // Navigation buttons (hidden in vertical mode) if (!isVertical) ...[ const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: isRtl ? [ IconButton( icon: const Icon(Icons.last_page, color: Colors.white), onPressed: currentIdx < totalItems - 1 ? () => activeController.jumpToPage(totalItems - 1) : null, iconSize: 28, ), IconButton( icon: const Icon(Icons.chevron_right, color: Colors.white), onPressed: currentIdx > 0 ? () => einkMode ? activeController.jumpToPage(currentIdx - 1) : activeController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.chevron_left, color: Colors.white), onPressed: currentIdx < totalItems - 1 ? () => einkMode ? activeController.jumpToPage(currentIdx + 1) : activeController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.first_page, color: Colors.white), onPressed: currentIdx > 0 ? () => activeController.jumpToPage(0) : null, iconSize: 28, ), ] : [ IconButton( icon: const Icon(Icons.first_page, color: Colors.white), onPressed: currentIdx > 0 ? () => activeController.jumpToPage(0) : null, iconSize: 28, ), IconButton( icon: const Icon(Icons.chevron_left, color: Colors.white), onPressed: currentIdx > 0 ? () => einkMode ? activeController.jumpToPage(currentIdx - 1) : activeController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.chevron_right, color: Colors.white), onPressed: currentIdx < totalItems - 1 ? () => einkMode ? activeController.jumpToPage(currentIdx + 1) : activeController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.last_page, color: Colors.white), onPressed: currentIdx < totalItems - 1 ? () => activeController.jumpToPage(totalItems - 1) : null, iconSize: 28, ), ], ), ], ], ), ); } Future _deleteCompletedProgress() async { _markAsReadOnServer(); await ref .read(readingProgressNotifierProvider.notifier) .deleteProgress(widget.entry.id, widget.serverId); if (mounted) { ref.invalidate(recentlyReadProvider); ref.invalidate(inProgressCountProvider); ref.invalidate(completedCountProvider); } } /// Dispose-safe counterpart of [_deleteCompletedProgress]. /// Wrapped in Future() to defer past widget tree finalization. void _deleteCompletedProgressDetached({ required bool isOnline, required Future serverFuture, required ServerSoftwareService softwareService, required ReadingProgressNotifier progressNotifier, }) { Future(() async { try { await _markAsReadDetached( isOnline: isOnline, serverFuture: serverFuture, softwareService: softwareService, ); await progressNotifier.deleteProgress(widget.entry.id, widget.serverId); } catch (e, stackTrace) { debugPrint( 'Failed to delete completed progress on dispose: $e\n$stackTrace'); } }); } void _initializeProgress() async { // Cache the publication entry (skip image downloads when offline) final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; final cachedPub = await ref.read(publicationCacheProvider.notifier).cachePublication( serverId: widget.serverId, entry: widget.entry, metadata: const EnhancedMetadata.empty(), downloadImages: isOnline, ); final progressKey = ProgressKey( publicationId: widget.entry.id, serverId: widget.serverId, ); final existingProgress = await ref.read(readingProgressProvider(progressKey).future); if (existingProgress == null) { // Create new progress final progress = ReadingProgress( id: const Uuid().v4(), publicationId: widget.entry.id, serverId: widget.serverId, currentPage: _currentPage, totalPages: widget.entry.streamLink!.pageCount, lastReadAt: DateTime.now(), publicationCacheId: cachedPub.id, seriesFeedUrl: widget.feedUrl, ); await ref .read(readingProgressNotifierProvider.notifier) .saveProgress(progress); // Invalidate to refresh library ref.invalidate(recentlyReadProvider); } else if (existingProgress.publicationCacheId == null || (widget.feedUrl != null && existingProgress.seriesFeedUrl == null)) { // Update existing progress with cache ID or feed URL if missing final updatedProgress = existingProgress.copyWith( publicationCacheId: cachedPub.id, lastReadAt: DateTime.now(), seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl, ); await ref .read(readingProgressNotifierProvider.notifier) .saveProgress(updatedProgress); // Invalidate to refresh library ref.invalidate(recentlyReadProvider); } // Pre-cache upcoming pages from the starting page _precacheUpcomingPages(_currentPage); } void _saveProgress(int page) async { final progressKey = ProgressKey( publicationId: widget.entry.id, serverId: widget.serverId, ); final existingProgress = await ref.read(readingProgressProvider(progressKey).future); // Get or cache the publication final cachedPub = await ref.read( cachedPublicationProvider(widget.serverId, widget.entry.id).future); if (existingProgress != null) { // Update existing progress with cache ID final updatedProgress = existingProgress.copyWith( currentPage: page, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id ?? existingProgress.publicationCacheId, seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl, ); await ref .read(readingProgressNotifierProvider.notifier) .saveProgress(updatedProgress); } else { // Create new progress final progress = ReadingProgress( id: const Uuid().v4(), publicationId: widget.entry.id, serverId: widget.serverId, currentPage: page, totalPages: widget.entry.streamLink!.pageCount, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id, seriesFeedUrl: widget.feedUrl, ); await ref .read(readingProgressNotifierProvider.notifier) .saveProgress(progress); } // Invalidate progress provider to refresh UI ref.invalidate(readingProgressProvider(progressKey)); ref.invalidate(recentlyReadProvider); } /// Dispose-safe version: all provider refs passed in as arguments. /// Wrapped in Future() to defer past widget tree finalization. void _saveProgressDetached( int page, { required Future progressFuture, required Future cachedPubFuture, required ReadingProgressNotifier progressNotifier, }) { Future(() async { try { final existingProgress = await progressFuture; final cachedPub = await cachedPubFuture; if (existingProgress != null) { final updatedProgress = existingProgress.copyWith( currentPage: page, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id ?? existingProgress.publicationCacheId, seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl, ); await progressNotifier.saveProgress(updatedProgress); } else { final progress = ReadingProgress( id: const Uuid().v4(), publicationId: widget.entry.id, serverId: widget.serverId, currentPage: page, totalPages: widget.entry.streamLink!.pageCount, lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id, seriesFeedUrl: widget.feedUrl, ); await progressNotifier.saveProgress(progress); } } catch (e, stackTrace) { debugPrint('Failed to save progress on dispose: $e\n$stackTrace'); } }); } /// Fire-and-forget page progress report so the server tracks reading. void _reportPageToServer(int page) { if (!ref.read(syncProgressNotifierProvider)) return; if (!widget.entry.hasStreamLink) return; final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; if (!isOnline) return; final server = ref.read(serverProvider(widget.serverId)).valueOrNull; if (server == null) return; final software = ref.read(serverSoftwareServiceProvider).getImplementation(server); software .reportPageProgress(server, widget.entry.streamLink!, page) .catchError((e) => debugPrint('reportPageToServer: $e')); } /// Mark the entry as read on the server via the appropriate implementation. void _markAsReadOnServer() async { debugPrint( 'markAsRead: called for entry=${widget.entry.id} server=${widget.serverId}'); final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false; if (!isOnline) { debugPrint('markAsRead: skipped — offline'); return; } try { final server = await ref.read(serverProvider(widget.serverId).future); if (server == null) return; final software = ref.read(serverSoftwareServiceProvider).getImplementation(server); await software.markAsRead(server, widget.entry); } catch (e) { debugPrint('markAsRead: error: $e'); } } /// Dispose-safe version: all provider refs passed in as arguments. Future _markAsReadDetached({ required bool isOnline, required Future serverFuture, required ServerSoftwareService softwareService, }) async { debugPrint( 'markAsRead: called for entry=${widget.entry.id} server=${widget.serverId}'); if (!isOnline) { debugPrint('markAsRead: skipped — offline'); return; } try { final server = await serverFuture; if (server == null) return; final software = softwareService.getImplementation(server); await software.markAsRead(server, widget.entry); } catch (e) { debugPrint('markAsRead: error: $e'); } } void _precacheUpcomingPages(int currentPage) { if (!mounted) return; final count = ref.read(precachePagesNotifierProvider); if (count == 0) return; final streamLink = widget.entry.streamLink; if (streamLink == null) return; // Server is only needed for network images; local content can precache without it final serverAsync = ref.read(serverProvider(widget.serverId)); final server = serverAsync.valueOrNull; if (server == null && widget.localContentPath == null) return; final totalPages = streamLink.pageCount; final dimNotifier = ref.read(pageDimensionsNotifierProvider(widget.entry.id).notifier); for (var i = currentPage + 1; i <= currentPage + count && i < totalPages; i++) { final imageProvider = _getPageImageProvider(i, streamLink, server); precacheImage(imageProvider, context).catchError((e) { debugPrint('Failed to precache page $i: $e'); }); // Also resolve dimensions as the image loads dimNotifier.resolvePage(i, imageProvider); } // Resolve current page dimensions too if (currentPage >= 0 && currentPage < totalPages) { final currentProvider = _getPageImageProvider(currentPage, streamLink, server); dimNotifier.resolvePage(currentPage, currentProvider); } } /// Get the image provider for a page, using local files if available ImageProvider _getPageImageProvider( int pageIndex, StreamLink streamLink, dynamic server, ) { if (widget.localContentPath != null) { final localFile = _findLocalPageFile(pageIndex); if (localFile != null) { return FileImage(localFile); } } return CachedNetworkImageProvider( streamLink.getPageUrl(pageIndex), headers: server != null ? AuthService().getAuthHeaders(server) : {}, ); } /// Build a page image widget for vertical scroll mode Widget _buildPageImage( int pageIndex, StreamLink streamLink, dynamic server, FilterQuality filterQuality, ) { if (widget.localContentPath != null) { final localFile = _findLocalPageFile(pageIndex); if (localFile != null) { return Image.file( localFile, filterQuality: filterQuality, width: double.infinity, fit: BoxFit.fitWidth, errorBuilder: (_, __, ___) => const SizedBox( height: 400, child: Center(child: Icon(Icons.error, color: Colors.white)), ), ); } } return CachedNetworkImage( imageUrl: streamLink.getPageUrl(pageIndex), httpHeaders: AuthService().getAuthHeaders(server), filterQuality: filterQuality, width: double.infinity, fit: BoxFit.fitWidth, placeholder: (context, url) => const SizedBox( height: 400, child: Center( child: CircularProgressIndicator(color: Colors.white), ), ), errorWidget: (context, url, error) => const SizedBox( height: 400, child: Center(child: Icon(Icons.error, color: Colors.white)), ), ); } /// Resolve the file extension used in the local content directory. /// Probes once using page 0 and caches the result for all subsequent calls. String? _resolveLocalExtension() { if (_localExtensionResolved) return _localFileExtension; _localExtensionResolved = true; if (widget.localContentPath == null) return null; for (final ext in ['.jpg', '.png', '.gif', '.webp', '.jpeg']) { if (File('${widget.localContentPath}/0$ext').existsSync()) { _localFileExtension = ext; return ext; } } return null; } /// Find a local page file using the cached extension File? _findLocalPageFile(int pageIndex) { if (widget.localContentPath == null) return null; final ext = _resolveLocalExtension(); if (ext == null) return null; final file = File('${widget.localContentPath}/$pageIndex$ext'); if (file.existsSync()) return file; return null; } }