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/models/enhanced_metadata.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/models/opds_stream_link.dart'; import 'package:worldhopper/models/reading_progress.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/filter_quality_provider.dart'; import 'package:worldhopper/providers/precache_pages_provider.dart'; import 'package:worldhopper/providers/reading_mode_provider.dart'; import 'package:worldhopper/services/auth_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 OPDSEntry entry; final String serverId; final int initialPage; final String? feedUrl; const ReaderScreen({ super.key, required this.entry, required this.serverId, this.initialPage = 0, this.feedUrl, }); @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; ReadingMode? _previousMode; @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(); } @override void dispose() { // If the user finished the chapter, remove from recently read; // otherwise save progress one final time before disposing. if (_reachedEndThisSession) { _deleteCompletedProgress(); } else { _saveProgress(_currentPage); } _pageController.dispose(); _scrollController?.dispose(); // Restore system UI SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // Allow screen to sleep again WakelockPlus.disable(); super.dispose(); } @override Widget build(BuildContext context) { if (!widget.entry.hasStreamLink) { return Scaffold( appBar: AppBar( title: const Text('Error'), ), body: const Center( child: Text('This publication cannot be read (no stream link)'), ), ); } 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, onPopInvoked: (bool didPop) { 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 const Center( child: Text( 'Server not found', style: TextStyle(color: Colors.white), ), ); } final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null ? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!)) : null; final readingMode = seriesOverride ?? globalMode; // 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; 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( 'Error loading server: $error', 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, onBeforeNavigate: _deleteCompletedProgress, ), ], ), ), ); } Widget _buildPagedReader(OPDSStreamLink streamLink, dynamic server, {required bool reverse}) { final filterQuality = ref.watch(filterQualityNotifierProvider); return GestureDetector( onTap: () { setState(() { _showControls = !_showControls; }); }, child: PhotoViewGallery.builder( scrollPhysics: const BouncingScrollPhysics(), reverse: reverse, builder: (context, index) { return PhotoViewGalleryPageOptions( imageProvider: CachedNetworkImageProvider( streamLink.getPageUrl(index), headers: AuthService().getAuthHeaders(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); _precacheUpcomingPages(index); }, ), ); } Widget _buildVerticalReader(OPDSStreamLink streamLink, dynamic server) { final filterQuality = ref.watch(filterQualityNotifierProvider); // Lazily initialize scroll controller _scrollController ??= ScrollController(); 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); } } return false; }, child: ListView.builder( controller: _scrollController, itemCount: streamLink.pageCount, itemBuilder: (context, index) { return InteractiveViewer( minScale: 1.0, maxScale: 3.0, child: CachedNetworkImage( imageUrl: streamLink.getPageUrl(index), 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), ), ), ), ); }, ), ), ); } Widget _buildTopBar(BuildContext context, OPDSStreamLink streamLink) { return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withOpacity(0.7), Colors.transparent, ], ), ), padding: EdgeInsets.only( top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 16, ), child: Consumer( builder: (context, ref, _) { // 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.withOpacity(0.7), fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), // Offline indicator if (isOffline) const Padding( padding: EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Colors.redAccent, semanticLabel: 'Offline', ), ), // 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); } } Widget _buildSettingsMenu(WidgetRef ref) { final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null ? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!)) : null; final readingMode = seriesOverride ?? globalMode; final String readingModeLabel = switch (readingMode) { ReadingMode.ltr => 'LTR', ReadingMode.rtl => 'RTL', ReadingMode.verticalContinuous => 'Vertical', }; 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: const Text('Left to right'), ), MenuItemButton( leadingIcon: const Icon(Icons.arrow_back), trailingIcon: readingMode == ReadingMode.rtl ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.rtl), child: const Text('Right to left'), ), MenuItemButton( leadingIcon: const Icon(Icons.swap_vert), trailingIcon: readingMode == ReadingMode.verticalContinuous ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.verticalContinuous), child: const Text('Vertical scroll'), ), ], child: Text('Reading direction ($readingModeLabel)'), ), ], ); } Widget _buildBottomBar(BuildContext context, OPDSStreamLink streamLink) { 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; return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withOpacity(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( 'Page ${_currentPage + 1} of ${streamLink.pageCount}', 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.withOpacity(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: _currentPage < streamLink.pageCount - 1 ? () => _pageController.jumpToPage(streamLink.pageCount - 1) : null, iconSize: 28, ), IconButton( icon: const Icon(Icons.chevron_right, color: Colors.white), onPressed: _currentPage > 0 ? () => _pageController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.chevron_left, color: Colors.white), onPressed: _currentPage < streamLink.pageCount - 1 ? () => _pageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.first_page, color: Colors.white), onPressed: _currentPage > 0 ? () => _pageController.jumpToPage(0) : null, iconSize: 28, ), ] : [ IconButton( icon: const Icon(Icons.first_page, color: Colors.white), onPressed: _currentPage > 0 ? () => _pageController.jumpToPage(0) : null, iconSize: 28, ), IconButton( icon: const Icon(Icons.chevron_left, color: Colors.white), onPressed: _currentPage > 0 ? () => _pageController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.chevron_right, color: Colors.white), onPressed: _currentPage < streamLink.pageCount - 1 ? () => _pageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ) : null, iconSize: 32, ), IconButton( icon: const Icon(Icons.last_page, color: Colors.white), onPressed: _currentPage < streamLink.pageCount - 1 ? () => _pageController.jumpToPage(streamLink.pageCount - 1) : null, iconSize: 28, ), ], ), ], ], ), ); } Future _deleteCompletedProgress() async { await ref.read(readingProgressNotifierProvider.notifier) .deleteProgress(widget.entry.id, widget.serverId); if (mounted) { ref.invalidate(recentlyReadProvider); ref.invalidate(inProgressCountProvider); ref.invalidate(completedCountProvider); } } void _initializeProgress() async { // Cache the publication entry final cachedPub = await ref.read(publicationCacheProvider.notifier).cachePublication( serverId: widget.serverId, entry: widget.entry, metadata: const EnhancedMetadata.empty(), downloadImages: true, ); 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); } 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; final serverAsync = ref.read(serverProvider(widget.serverId)); final server = serverAsync.valueOrNull; if (server == null) return; final authHeaders = AuthService().getAuthHeaders(server); final totalPages = streamLink.pageCount; for (var i = currentPage + 1; i <= currentPage + count && i < totalPages; i++) { precacheImage( CachedNetworkImageProvider( streamLink.getPageUrl(i), headers: authHeaders, ), context, ).catchError((_) {}); } } }