worldhopper/lib/screens/reader/reader_screen.dart
Felipe M. da7d11b33a
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
feat: reading direction per series
2026-02-12 09:15:28 +01:00

774 lines
26 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/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<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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<ScrollNotification>(
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<Color>(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<void> _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((_) {});
}
}
}