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/services/auth_service.dart'; import 'package:uuid/uuid.dart'; /// Main reader screen for page-by-page reading class ReaderScreen extends ConsumerStatefulWidget { final OPDSEntry entry; final String serverId; final int initialPage; const ReaderScreen({ super.key, required this.entry, required this.serverId, this.initialPage = 0, }); @override ConsumerState createState() => _ReaderScreenState(); } class _ReaderScreenState extends ConsumerState { late PageController _pageController; late int _currentPage; bool _showControls = true; bool _isInitialized = false; @override void initState() { super.initState(); _currentPage = widget.initialPage; _pageController = PageController(initialPage: widget.initialPage); // Enter fullscreen immersive mode SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); // Keep screen awake while reading WakelockPlus.enable(); } @override void dispose() { // Save progress one final time before disposing _saveProgress(_currentPage); _pageController.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) { // Save progress one final time when popping _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 filterQuality = ref.watch(filterQualityNotifierProvider); return GestureDetector( onTap: () { setState(() { _showControls = !_showControls; }); }, child: PhotoViewGallery.builder( scrollPhysics: const BouncingScrollPhysics(), 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; }); _saveProgress(index); _precacheUpcomingPages(index); }, ), ); }, 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), ), ], ), ), ); } 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', ), ), ], ); }, ), ); } Widget _buildBottomBar(BuildContext context, OPDSStreamLink streamLink) { final progress = (_currentPage + 1) / streamLink.pageCount; 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, ), const SizedBox(height: 12), // Navigation buttons Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ 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, ), ], ), ], ), ); } 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, ); await ref .read(readingProgressNotifierProvider.notifier) .saveProgress(progress); // Invalidate to refresh library ref.invalidate(recentlyReadProvider); } else if (existingProgress.publicationCacheId == null) { // Update existing progress with cache ID if missing final updatedProgress = existingProgress.copyWith( publicationCacheId: cachedPub.id, lastReadAt: DateTime.now(), ); 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, ); 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, ); 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((_) {}); } } }