worldhopper/lib/screens/reader/reader_screen.dart
Felipe M. 696e963434
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
feat: pre-cache next pages in opds reader
2026-02-10 21:59:11 +01:00

507 lines
16 KiB
Dart

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<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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<Color>(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((_) {});
}
}
}