All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
774 lines
26 KiB
Dart
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((_) {});
|
|
}
|
|
}
|
|
}
|