Rename all OPDS-prefixed models to generic names (Server, Feed, Entry, Link, StreamLink), expand the ServerSoftware interface to cover all server interactions, and implement a full Kavita REST API client that replaces the OPDS delegation. - Rename OPDS* models to generic names across ~60 files - Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials) - Create OPDSServerSoftware wrapping existing OPDS services - Create KavitaApiClient for direct Kavita REST API calls - Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models - Rewrite KavitaServerSoftware to use native API (no OPDS delegation) - Unify server credentials (remove softwareUsername/softwarePassword) - Simplify add/edit server UI to single auth section - Add test connection button to server add/edit screens - Add progress indicator to PublicationCard using local and server data - Eliminate softwareType branching in reader screens - Add EntryProgress and fetchEntryProgress to ServerSoftware interface - Fix Entry.acquisitionLink crash on empty links - Add 59 new tests covering models, services, and providers
1344 lines
47 KiB
Dart
1344 lines
47 KiB
Dart
import 'dart:io';
|
|
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/l10n/app_localizations.dart';
|
|
import 'package:worldhopper/helpers/l10n_helpers.dart';
|
|
import 'package:worldhopper/models/enhanced_metadata.dart';
|
|
import 'package:worldhopper/models/entry.dart';
|
|
import 'package:worldhopper/models/server.dart';
|
|
import 'package:worldhopper/models/publication.dart';
|
|
import 'package:worldhopper/models/stream_link.dart';
|
|
import 'package:worldhopper/models/reading_progress.dart';
|
|
import 'package:worldhopper/providers/sync_progress_provider.dart';
|
|
import 'package:worldhopper/providers/cover_page_provider.dart';
|
|
import 'package:worldhopper/providers/page_dimensions_provider.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/server_software_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/providers/eink_mode_provider.dart';
|
|
import 'package:worldhopper/providers/two_page_mode_provider.dart';
|
|
import 'package:worldhopper/screens/reader/page_grouping.dart';
|
|
import 'package:worldhopper/screens/reader/two_page_reader.dart';
|
|
import 'package:worldhopper/services/auth_service.dart';
|
|
import 'package:worldhopper/services/server_software/server_software_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 Entry entry;
|
|
final String serverId;
|
|
final int initialPage;
|
|
final String? feedUrl;
|
|
final String? localContentPath;
|
|
|
|
const ReaderScreen({
|
|
super.key,
|
|
required this.entry,
|
|
required this.serverId,
|
|
this.initialPage = 0,
|
|
this.feedUrl,
|
|
this.localContentPath,
|
|
});
|
|
|
|
@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;
|
|
bool _needsVerticalScrollToPage = false;
|
|
ReadingMode? _previousMode;
|
|
|
|
// Two-page spread state
|
|
PageController? _twoPageController;
|
|
List<PageSpread> _spreads = [];
|
|
bool _wasTwoPage = false;
|
|
|
|
// E-ink swipe tracking
|
|
double? _einkSwipeStartX;
|
|
|
|
// Cached file extension for local content (avoids probing 5 extensions per page)
|
|
String? _localFileExtension;
|
|
bool _localExtensionResolved = false;
|
|
|
|
@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();
|
|
}
|
|
|
|
// Provider refs captured in deactivate() for use in dispose().
|
|
// Riverpod invalidates `ref` after deactivate() completes, so reading
|
|
// providers in dispose() throws. We snapshot the values we need here.
|
|
bool _capturedIsOnline = false;
|
|
Future<Server?>? _capturedServerFuture;
|
|
ServerSoftwareService? _capturedSoftwareService;
|
|
ReadingProgressNotifier? _capturedProgressNotifier;
|
|
Future<ReadingProgress?>? _capturedProgressFuture;
|
|
Future<Publication?>? _capturedCachedPubFuture;
|
|
|
|
@override
|
|
void deactivate() {
|
|
// Capture all provider references while ref is still valid.
|
|
_capturedIsOnline =
|
|
ref.read(connectivityStateProvider).valueOrNull ?? false;
|
|
_capturedServerFuture = ref.read(serverProvider(widget.serverId).future);
|
|
_capturedSoftwareService = ref.read(serverSoftwareServiceProvider);
|
|
_capturedProgressNotifier =
|
|
ref.read(readingProgressNotifierProvider.notifier);
|
|
_capturedProgressFuture = ref.read(readingProgressProvider(ProgressKey(
|
|
publicationId: widget.entry.id,
|
|
serverId: widget.serverId,
|
|
)).future);
|
|
_capturedCachedPubFuture = ref.read(
|
|
cachedPublicationProvider(widget.serverId, widget.entry.id).future);
|
|
super.deactivate();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// If the user finished the chapter, remove from recently read;
|
|
// otherwise save progress one final time before disposing.
|
|
// Guard against deactivate() not having been called (captured fields null).
|
|
if (_reachedEndThisSession) {
|
|
if (_capturedProgressNotifier != null &&
|
|
_capturedServerFuture != null &&
|
|
_capturedSoftwareService != null) {
|
|
_deleteCompletedProgressDetached(
|
|
isOnline: _capturedIsOnline,
|
|
serverFuture: _capturedServerFuture!,
|
|
softwareService: _capturedSoftwareService!,
|
|
progressNotifier: _capturedProgressNotifier!,
|
|
);
|
|
}
|
|
} else {
|
|
if (_capturedProgressNotifier != null &&
|
|
_capturedProgressFuture != null &&
|
|
_capturedCachedPubFuture != null) {
|
|
_saveProgressDetached(
|
|
_currentPage,
|
|
progressFuture: _capturedProgressFuture!,
|
|
cachedPubFuture: _capturedCachedPubFuture!,
|
|
progressNotifier: _capturedProgressNotifier!,
|
|
);
|
|
}
|
|
}
|
|
|
|
_pageController.dispose();
|
|
_twoPageController?.dispose();
|
|
_scrollController?.dispose();
|
|
// Restore system UI
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
// Allow screen to sleep again
|
|
WakelockPlus.disable();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
if (!widget.entry.hasStreamLink) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(l10n.readerError),
|
|
),
|
|
body: Center(
|
|
child: Text(l10n.readerNoStreamLink),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
onPopInvokedWithResult: (bool didPop, Object? result) {
|
|
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 Center(
|
|
child: Text(
|
|
l10n.readerServerNotFound,
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
);
|
|
}
|
|
|
|
final globalMode = ref.watch(readingModeNotifierProvider);
|
|
final seriesOverride = widget.feedUrl != null
|
|
? ref.watch(
|
|
seriesReadingModeNotifierProvider(widget.feedUrl!))
|
|
: null;
|
|
final readingMode = seriesOverride ?? globalMode;
|
|
|
|
// Determine if two-page mode should activate
|
|
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
|
|
final seriesToPageOverride = widget.feedUrl != null
|
|
? ref.watch(
|
|
seriesToPageModeNotifierProvider(widget.feedUrl!))
|
|
: null;
|
|
final twoPageMode = seriesToPageOverride ?? globalTwoPage;
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isPaged = readingMode != ReadingMode.verticalContinuous;
|
|
final useTwoPage = isPaged &&
|
|
twoPageMode == TwoPageMode.auto &&
|
|
screenWidth >= 768;
|
|
|
|
// 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;
|
|
|
|
// Handle transition between single-page and two-page modes
|
|
if (_wasTwoPage && !useTwoPage) {
|
|
// Transitioning from two-page to single-page
|
|
_wasTwoPage = false;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_pageController.hasClients) {
|
|
_pageController.jumpToPage(_currentPage);
|
|
}
|
|
});
|
|
} else if (!_wasTwoPage && useTwoPage) {
|
|
// Transitioning from single-page to two-page
|
|
_wasTwoPage = true;
|
|
}
|
|
|
|
if (useTwoPage) {
|
|
final isRtl = readingMode == ReadingMode.rtl;
|
|
final globalCover = ref.watch(coverPageNotifierProvider);
|
|
final seriesCoverOverride = widget.feedUrl != null
|
|
? ref.watch(
|
|
seriesCoverPageNotifierProvider(widget.feedUrl!))
|
|
: null;
|
|
final firstPageIsCover = seriesCoverOverride ?? globalCover;
|
|
return _buildTwoPageReader(streamLink, server,
|
|
rtl: isRtl, firstPageIsCover: firstPageIsCover);
|
|
}
|
|
|
|
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(
|
|
l10n.readerErrorLoadingServer(error.toString()),
|
|
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,
|
|
einkMode: ref.watch(einkModeNotifierProvider),
|
|
isDownloadedContent: widget.localContentPath != null,
|
|
onBeforeNavigate: _deleteCompletedProgress,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPagedReader(StreamLink streamLink, dynamic server,
|
|
{required bool reverse}) {
|
|
final filterQuality = ref.watch(filterQualityNotifierProvider);
|
|
final einkMode = ref.watch(einkModeNotifierProvider);
|
|
|
|
Widget gallery = GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
_showControls = !_showControls;
|
|
});
|
|
},
|
|
child: PhotoViewGallery.builder(
|
|
scrollPhysics: einkMode
|
|
? const NeverScrollableScrollPhysics()
|
|
: const BouncingScrollPhysics(),
|
|
reverse: reverse,
|
|
builder: (context, index) {
|
|
return PhotoViewGalleryPageOptions(
|
|
imageProvider: _getPageImageProvider(index, streamLink, 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);
|
|
_reportPageToServer(index);
|
|
_precacheUpcomingPages(index);
|
|
},
|
|
),
|
|
);
|
|
|
|
if (einkMode) {
|
|
gallery = _wrapWithEinkSwipe(
|
|
child: gallery,
|
|
controller: _pageController,
|
|
itemCount: streamLink.pageCount,
|
|
reverse: reverse,
|
|
);
|
|
}
|
|
|
|
return gallery;
|
|
}
|
|
|
|
Widget _buildTwoPageReader(StreamLink streamLink, dynamic server,
|
|
{required bool rtl, required bool firstPageIsCover}) {
|
|
final filterQuality = ref.watch(filterQualityNotifierProvider);
|
|
final dimensions =
|
|
ref.watch(pageDimensionsNotifierProvider(widget.entry.id));
|
|
|
|
// Recompute spreads whenever dimensions change
|
|
_spreads = computePageSpreads(
|
|
pageCount: streamLink.pageCount,
|
|
dimensions: dimensions,
|
|
rtl: rtl,
|
|
firstPageIsCover: firstPageIsCover,
|
|
);
|
|
|
|
// Find the spread index for the current page
|
|
final currentSpreadIndex = spreadIndexForPage(_spreads, _currentPage);
|
|
|
|
// Create or update the two-page controller
|
|
if (_twoPageController == null || !_twoPageController!.hasClients) {
|
|
_twoPageController?.dispose();
|
|
_twoPageController = PageController(initialPage: currentSpreadIndex);
|
|
}
|
|
|
|
final einkMode = ref.watch(einkModeNotifierProvider);
|
|
|
|
Widget reader = TwoPageReader(
|
|
spreads: _spreads,
|
|
pageController: _twoPageController!,
|
|
getImageProvider: (index) =>
|
|
_getPageImageProvider(index, streamLink, server),
|
|
filterQuality: filterQuality,
|
|
reverse: rtl,
|
|
einkMode: einkMode,
|
|
onTap: () {
|
|
setState(() {
|
|
_showControls = !_showControls;
|
|
});
|
|
},
|
|
onPageChanged: (spreadIndex) {
|
|
if (spreadIndex < 0 || spreadIndex >= _spreads.length) return;
|
|
final primaryPage = primaryPageForSpread(_spreads, spreadIndex);
|
|
final lastPage = lastPageInSpread(_spreads, spreadIndex);
|
|
setState(() {
|
|
_currentPage = primaryPage;
|
|
if (lastPage >= streamLink.pageCount - 1) {
|
|
_reachedEndThisSession = true;
|
|
}
|
|
});
|
|
_saveProgress(primaryPage);
|
|
_reportPageToServer(lastPage);
|
|
_precacheUpcomingPages(lastPage);
|
|
},
|
|
);
|
|
|
|
if (einkMode) {
|
|
reader = _wrapWithEinkSwipe(
|
|
child: reader,
|
|
controller: _twoPageController!,
|
|
itemCount: _spreads.length,
|
|
reverse: rtl,
|
|
);
|
|
}
|
|
|
|
return reader;
|
|
}
|
|
|
|
Widget _buildVerticalReader(StreamLink streamLink, dynamic server) {
|
|
final filterQuality = ref.watch(filterQualityNotifierProvider);
|
|
|
|
// Lazily initialize scroll controller
|
|
if (_scrollController == null) {
|
|
_scrollController = ScrollController();
|
|
if (_currentPage > 0) {
|
|
_needsVerticalScrollToPage = true;
|
|
}
|
|
}
|
|
|
|
// Restore scroll position to the saved page on resume
|
|
if (_needsVerticalScrollToPage) {
|
|
_needsVerticalScrollToPage = false;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController != null &&
|
|
_scrollController!.hasClients &&
|
|
_scrollController!.position.maxScrollExtent > 0) {
|
|
final pageCount = streamLink.pageCount;
|
|
if (pageCount <= 1) return;
|
|
final targetOffset = (_currentPage / (pageCount - 1)) *
|
|
_scrollController!.position.maxScrollExtent;
|
|
_scrollController!.jumpTo(
|
|
targetOffset.clamp(
|
|
0.0, _scrollController!.position.maxScrollExtent),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
_reportPageToServer(clampedPage);
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
itemCount: streamLink.pageCount,
|
|
itemBuilder: (context, index) {
|
|
return InteractiveViewer(
|
|
minScale: 1.0,
|
|
maxScale: 3.0,
|
|
child: _buildPageImage(index, streamLink, server, filterQuality),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Wraps [child] with a [Listener] that detects horizontal swipes and calls
|
|
/// [jumpToPage] on [controller], giving instant page turns with no animation.
|
|
Widget _wrapWithEinkSwipe({
|
|
required Widget child,
|
|
required PageController controller,
|
|
required int itemCount,
|
|
required bool reverse,
|
|
}) {
|
|
return Listener(
|
|
onPointerDown: (e) => _einkSwipeStartX = e.position.dx,
|
|
onPointerUp: (e) {
|
|
if (_einkSwipeStartX == null) return;
|
|
final dx = e.position.dx - _einkSwipeStartX!;
|
|
_einkSwipeStartX = null;
|
|
if (dx.abs() < 50) return; // below swipe threshold
|
|
|
|
// Swipe left (dx < 0) → next page, swipe right (dx > 0) → previous
|
|
final direction = dx < 0 ? 1 : -1;
|
|
final effectiveDirection = reverse ? -direction : direction;
|
|
final currentIdx = controller.page?.round() ?? 0;
|
|
final target = currentIdx + effectiveDirection;
|
|
if (target >= 0 && target < itemCount) {
|
|
controller.jumpToPage(target);
|
|
}
|
|
},
|
|
onPointerCancel: (_) => _einkSwipeStartX = null,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
Widget _buildTopBar(BuildContext context, StreamLink streamLink) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withValues(alpha: 0.7),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
padding: EdgeInsets.only(
|
|
top: MediaQuery.of(context).padding.top,
|
|
left: 8,
|
|
right: 8,
|
|
bottom: 16,
|
|
),
|
|
child: Consumer(
|
|
builder: (context, ref, _) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
// 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.withValues(alpha: 0.7),
|
|
fontSize: 12,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Offline indicator
|
|
if (isOffline)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Icon(
|
|
Icons.cloud_off,
|
|
color: Colors.redAccent,
|
|
semanticLabel: l10n.offlineSemanticLabel,
|
|
),
|
|
),
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
void _setTwoPageMode(WidgetRef ref, TwoPageMode mode) {
|
|
if (widget.feedUrl != null) {
|
|
ref
|
|
.read(seriesToPageModeNotifierProvider(widget.feedUrl!).notifier)
|
|
.setTwoPageMode(mode);
|
|
} else {
|
|
ref.read(twoPageModeNotifierProvider.notifier).setTwoPageMode(mode);
|
|
}
|
|
}
|
|
|
|
void _setCoverPage(WidgetRef ref, bool value) {
|
|
if (widget.feedUrl != null) {
|
|
ref
|
|
.read(seriesCoverPageNotifierProvider(widget.feedUrl!).notifier)
|
|
.setCoverPage(value);
|
|
} else {
|
|
ref.read(coverPageNotifierProvider.notifier).setCoverPage(value);
|
|
}
|
|
}
|
|
|
|
Widget _buildSettingsMenu(WidgetRef ref) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
final globalMode = ref.watch(readingModeNotifierProvider);
|
|
final seriesOverride = widget.feedUrl != null
|
|
? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!))
|
|
: null;
|
|
final readingMode = seriesOverride ?? globalMode;
|
|
|
|
final modeShortLabel = readingModeShortLabel(l10n, readingMode);
|
|
|
|
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
|
|
final seriesToPageOverride = widget.feedUrl != null
|
|
? ref.watch(seriesToPageModeNotifierProvider(widget.feedUrl!))
|
|
: null;
|
|
final twoPageMode = seriesToPageOverride ?? globalTwoPage;
|
|
final isPaged = readingMode != ReadingMode.verticalContinuous;
|
|
|
|
final globalCover = ref.watch(coverPageNotifierProvider);
|
|
final seriesCoverOverride = widget.feedUrl != null
|
|
? ref.watch(seriesCoverPageNotifierProvider(widget.feedUrl!))
|
|
: null;
|
|
final firstPageIsCover = seriesCoverOverride ?? globalCover;
|
|
|
|
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: Text(l10n.readingModeLtr),
|
|
),
|
|
MenuItemButton(
|
|
leadingIcon: const Icon(Icons.arrow_back),
|
|
trailingIcon: readingMode == ReadingMode.rtl
|
|
? const Icon(Icons.check, size: 18)
|
|
: null,
|
|
onPressed: () => _setReadingMode(ref, ReadingMode.rtl),
|
|
child: Text(l10n.readingModeRtl),
|
|
),
|
|
MenuItemButton(
|
|
leadingIcon: const Icon(Icons.swap_vert),
|
|
trailingIcon: readingMode == ReadingMode.verticalContinuous
|
|
? const Icon(Icons.check, size: 18)
|
|
: null,
|
|
onPressed: () =>
|
|
_setReadingMode(ref, ReadingMode.verticalContinuous),
|
|
child: Text(l10n.readingModeVertical),
|
|
),
|
|
],
|
|
child: Text(l10n.readerReadingDirectionWithMode(modeShortLabel)),
|
|
),
|
|
if (isPaged)
|
|
MenuItemButton(
|
|
leadingIcon: const Icon(Icons.auto_stories),
|
|
trailingIcon: twoPageMode == TwoPageMode.auto
|
|
? const Icon(Icons.check, size: 18)
|
|
: null,
|
|
onPressed: () {
|
|
final newMode = twoPageMode == TwoPageMode.auto
|
|
? TwoPageMode.off
|
|
: TwoPageMode.auto;
|
|
_setTwoPageMode(ref, newMode);
|
|
},
|
|
child: Text(l10n.readersTwoPageSpread),
|
|
),
|
|
if (isPaged)
|
|
MenuItemButton(
|
|
leadingIcon: const Icon(Icons.looks_one),
|
|
trailingIcon:
|
|
firstPageIsCover ? const Icon(Icons.check, size: 18) : null,
|
|
onPressed: () => _setCoverPage(ref, !firstPageIsCover),
|
|
child: Text(l10n.readersFirstPageIsCover),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomBar(BuildContext context, StreamLink streamLink) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final einkMode = ref.watch(einkModeNotifierProvider);
|
|
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;
|
|
|
|
// Determine if we're in two-page mode
|
|
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
|
|
final seriesToPageOverride = widget.feedUrl != null
|
|
? ref.watch(seriesToPageModeNotifierProvider(widget.feedUrl!))
|
|
: null;
|
|
final twoPageMode = seriesToPageOverride ?? globalTwoPage;
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isPaged = readingMode != ReadingMode.verticalContinuous;
|
|
final isTwoPage = isPaged &&
|
|
twoPageMode == TwoPageMode.auto &&
|
|
screenWidth >= 768 &&
|
|
_spreads.isNotEmpty;
|
|
|
|
// Build page indicator text
|
|
String pageIndicator;
|
|
if (isTwoPage) {
|
|
final spreadIdx = spreadIndexForPage(_spreads, _currentPage);
|
|
final spread = _spreads[spreadIdx];
|
|
if (spread.isPair) {
|
|
final lo = spread.primaryPage + 1;
|
|
final hi = spread.lastPage + 1;
|
|
pageIndicator = l10n.readerPagesOf(lo, hi, streamLink.pageCount);
|
|
} else {
|
|
pageIndicator =
|
|
l10n.readerPageOf(_currentPage + 1, streamLink.pageCount);
|
|
}
|
|
} else {
|
|
pageIndicator = l10n.readerPageOf(_currentPage + 1, streamLink.pageCount);
|
|
}
|
|
|
|
// Select active controller for navigation buttons
|
|
final activeController = isTwoPage && _twoPageController != null
|
|
? _twoPageController!
|
|
: _pageController;
|
|
final totalItems = isTwoPage ? _spreads.length : streamLink.pageCount;
|
|
final currentIdx =
|
|
isTwoPage ? spreadIndexForPage(_spreads, _currentPage) : _currentPage;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.bottomCenter,
|
|
end: Alignment.topCenter,
|
|
colors: [
|
|
Colors.black.withValues(alpha: 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(
|
|
pageIndicator,
|
|
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.withValues(alpha: 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: currentIdx < totalItems - 1
|
|
? () => activeController.jumpToPage(totalItems - 1)
|
|
: null,
|
|
iconSize: 28,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_right,
|
|
color: Colors.white),
|
|
onPressed: currentIdx > 0
|
|
? () => einkMode
|
|
? activeController.jumpToPage(currentIdx - 1)
|
|
: activeController.previousPage(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
)
|
|
: null,
|
|
iconSize: 32,
|
|
),
|
|
IconButton(
|
|
icon:
|
|
const Icon(Icons.chevron_left, color: Colors.white),
|
|
onPressed: currentIdx < totalItems - 1
|
|
? () => einkMode
|
|
? activeController.jumpToPage(currentIdx + 1)
|
|
: activeController.nextPage(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
)
|
|
: null,
|
|
iconSize: 32,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.first_page, color: Colors.white),
|
|
onPressed: currentIdx > 0
|
|
? () => activeController.jumpToPage(0)
|
|
: null,
|
|
iconSize: 28,
|
|
),
|
|
]
|
|
: [
|
|
IconButton(
|
|
icon: const Icon(Icons.first_page, color: Colors.white),
|
|
onPressed: currentIdx > 0
|
|
? () => activeController.jumpToPage(0)
|
|
: null,
|
|
iconSize: 28,
|
|
),
|
|
IconButton(
|
|
icon:
|
|
const Icon(Icons.chevron_left, color: Colors.white),
|
|
onPressed: currentIdx > 0
|
|
? () => einkMode
|
|
? activeController.jumpToPage(currentIdx - 1)
|
|
: activeController.previousPage(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
)
|
|
: null,
|
|
iconSize: 32,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_right,
|
|
color: Colors.white),
|
|
onPressed: currentIdx < totalItems - 1
|
|
? () => einkMode
|
|
? activeController.jumpToPage(currentIdx + 1)
|
|
: activeController.nextPage(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
)
|
|
: null,
|
|
iconSize: 32,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.last_page, color: Colors.white),
|
|
onPressed: currentIdx < totalItems - 1
|
|
? () => activeController.jumpToPage(totalItems - 1)
|
|
: null,
|
|
iconSize: 28,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteCompletedProgress() async {
|
|
_markAsReadOnServer();
|
|
await ref
|
|
.read(readingProgressNotifierProvider.notifier)
|
|
.deleteProgress(widget.entry.id, widget.serverId);
|
|
if (mounted) {
|
|
ref.invalidate(recentlyReadProvider);
|
|
ref.invalidate(inProgressCountProvider);
|
|
ref.invalidate(completedCountProvider);
|
|
}
|
|
}
|
|
|
|
/// Dispose-safe counterpart of [_deleteCompletedProgress].
|
|
/// Wrapped in Future() to defer past widget tree finalization.
|
|
void _deleteCompletedProgressDetached({
|
|
required bool isOnline,
|
|
required Future<Server?> serverFuture,
|
|
required ServerSoftwareService softwareService,
|
|
required ReadingProgressNotifier progressNotifier,
|
|
}) {
|
|
Future(() async {
|
|
try {
|
|
await _markAsReadDetached(
|
|
isOnline: isOnline,
|
|
serverFuture: serverFuture,
|
|
softwareService: softwareService,
|
|
);
|
|
await progressNotifier.deleteProgress(widget.entry.id, widget.serverId);
|
|
} catch (e, stackTrace) {
|
|
debugPrint(
|
|
'Failed to delete completed progress on dispose: $e\n$stackTrace');
|
|
}
|
|
});
|
|
}
|
|
|
|
void _initializeProgress() async {
|
|
// Cache the publication entry (skip image downloads when offline)
|
|
final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false;
|
|
final cachedPub =
|
|
await ref.read(publicationCacheProvider.notifier).cachePublication(
|
|
serverId: widget.serverId,
|
|
entry: widget.entry,
|
|
metadata: const EnhancedMetadata.empty(),
|
|
downloadImages: isOnline,
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
/// Dispose-safe version: all provider refs passed in as arguments.
|
|
/// Wrapped in Future() to defer past widget tree finalization.
|
|
void _saveProgressDetached(
|
|
int page, {
|
|
required Future<ReadingProgress?> progressFuture,
|
|
required Future<Publication?> cachedPubFuture,
|
|
required ReadingProgressNotifier progressNotifier,
|
|
}) {
|
|
Future(() async {
|
|
try {
|
|
final existingProgress = await progressFuture;
|
|
final cachedPub = await cachedPubFuture;
|
|
|
|
if (existingProgress != null) {
|
|
final updatedProgress = existingProgress.copyWith(
|
|
currentPage: page,
|
|
lastReadAt: DateTime.now(),
|
|
publicationCacheId:
|
|
cachedPub?.id ?? existingProgress.publicationCacheId,
|
|
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
|
|
);
|
|
await progressNotifier.saveProgress(updatedProgress);
|
|
} else {
|
|
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 progressNotifier.saveProgress(progress);
|
|
}
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Failed to save progress on dispose: $e\n$stackTrace');
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Fire-and-forget page progress report so the server tracks reading.
|
|
void _reportPageToServer(int page) {
|
|
if (!ref.read(syncProgressNotifierProvider)) return;
|
|
if (!widget.entry.hasStreamLink) return;
|
|
final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false;
|
|
if (!isOnline) return;
|
|
final server = ref.read(serverProvider(widget.serverId)).valueOrNull;
|
|
if (server == null) return;
|
|
final software =
|
|
ref.read(serverSoftwareServiceProvider).getImplementation(server);
|
|
software
|
|
.reportPageProgress(server, widget.entry.streamLink!, page)
|
|
.catchError((e) => debugPrint('reportPageToServer: $e'));
|
|
}
|
|
|
|
/// Mark the entry as read on the server via the appropriate implementation.
|
|
void _markAsReadOnServer() async {
|
|
debugPrint(
|
|
'markAsRead: called for entry=${widget.entry.id} server=${widget.serverId}');
|
|
final isOnline = ref.read(connectivityStateProvider).valueOrNull ?? false;
|
|
if (!isOnline) {
|
|
debugPrint('markAsRead: skipped — offline');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final server = await ref.read(serverProvider(widget.serverId).future);
|
|
if (server == null) return;
|
|
final software =
|
|
ref.read(serverSoftwareServiceProvider).getImplementation(server);
|
|
await software.markAsRead(server, widget.entry);
|
|
} catch (e) {
|
|
debugPrint('markAsRead: error: $e');
|
|
}
|
|
}
|
|
|
|
/// Dispose-safe version: all provider refs passed in as arguments.
|
|
Future<void> _markAsReadDetached({
|
|
required bool isOnline,
|
|
required Future<Server?> serverFuture,
|
|
required ServerSoftwareService softwareService,
|
|
}) async {
|
|
debugPrint(
|
|
'markAsRead: called for entry=${widget.entry.id} server=${widget.serverId}');
|
|
if (!isOnline) {
|
|
debugPrint('markAsRead: skipped — offline');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final server = await serverFuture;
|
|
if (server == null) return;
|
|
final software = softwareService.getImplementation(server);
|
|
await software.markAsRead(server, widget.entry);
|
|
} catch (e) {
|
|
debugPrint('markAsRead: error: $e');
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// Server is only needed for network images; local content can precache without it
|
|
final serverAsync = ref.read(serverProvider(widget.serverId));
|
|
final server = serverAsync.valueOrNull;
|
|
if (server == null && widget.localContentPath == null) return;
|
|
|
|
final totalPages = streamLink.pageCount;
|
|
final dimNotifier =
|
|
ref.read(pageDimensionsNotifierProvider(widget.entry.id).notifier);
|
|
|
|
for (var i = currentPage + 1;
|
|
i <= currentPage + count && i < totalPages;
|
|
i++) {
|
|
final imageProvider = _getPageImageProvider(i, streamLink, server);
|
|
precacheImage(imageProvider, context).catchError((e) {
|
|
debugPrint('Failed to precache page $i: $e');
|
|
});
|
|
// Also resolve dimensions as the image loads
|
|
dimNotifier.resolvePage(i, imageProvider);
|
|
}
|
|
|
|
// Resolve current page dimensions too
|
|
if (currentPage >= 0 && currentPage < totalPages) {
|
|
final currentProvider =
|
|
_getPageImageProvider(currentPage, streamLink, server);
|
|
dimNotifier.resolvePage(currentPage, currentProvider);
|
|
}
|
|
}
|
|
|
|
/// Get the image provider for a page, using local files if available
|
|
ImageProvider _getPageImageProvider(
|
|
int pageIndex,
|
|
StreamLink streamLink,
|
|
dynamic server,
|
|
) {
|
|
if (widget.localContentPath != null) {
|
|
final localFile = _findLocalPageFile(pageIndex);
|
|
if (localFile != null) {
|
|
return FileImage(localFile);
|
|
}
|
|
}
|
|
return CachedNetworkImageProvider(
|
|
streamLink.getPageUrl(pageIndex),
|
|
headers: server != null ? AuthService().getAuthHeaders(server) : {},
|
|
);
|
|
}
|
|
|
|
/// Build a page image widget for vertical scroll mode
|
|
Widget _buildPageImage(
|
|
int pageIndex,
|
|
StreamLink streamLink,
|
|
dynamic server,
|
|
FilterQuality filterQuality,
|
|
) {
|
|
if (widget.localContentPath != null) {
|
|
final localFile = _findLocalPageFile(pageIndex);
|
|
if (localFile != null) {
|
|
return Image.file(
|
|
localFile,
|
|
filterQuality: filterQuality,
|
|
width: double.infinity,
|
|
fit: BoxFit.fitWidth,
|
|
errorBuilder: (_, __, ___) => const SizedBox(
|
|
height: 400,
|
|
child: Center(child: Icon(Icons.error, color: Colors.white)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return CachedNetworkImage(
|
|
imageUrl: streamLink.getPageUrl(pageIndex),
|
|
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)),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Resolve the file extension used in the local content directory.
|
|
/// Probes once using page 0 and caches the result for all subsequent calls.
|
|
String? _resolveLocalExtension() {
|
|
if (_localExtensionResolved) return _localFileExtension;
|
|
_localExtensionResolved = true;
|
|
if (widget.localContentPath == null) return null;
|
|
for (final ext in ['.jpg', '.png', '.gif', '.webp', '.jpeg']) {
|
|
if (File('${widget.localContentPath}/0$ext').existsSync()) {
|
|
_localFileExtension = ext;
|
|
return ext;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Find a local page file using the cached extension
|
|
File? _findLocalPageFile(int pageIndex) {
|
|
if (widget.localContentPath == null) return null;
|
|
final ext = _resolveLocalExtension();
|
|
if (ext == null) return null;
|
|
final file = File('${widget.localContentPath}/$pageIndex$ext');
|
|
if (file.existsSync()) return file;
|
|
return null;
|
|
}
|
|
}
|