diff --git a/lib/data/offline/offline_stub_apis.dart b/lib/data/offline/offline_stub_apis.dart index 83fbd83285..5df93f1340 100644 --- a/lib/data/offline/offline_stub_apis.dart +++ b/lib/data/offline/offline_stub_apis.dart @@ -99,6 +99,19 @@ class OfflineUserLibraryApi implements UserLibraryApi { throw offlineUnavailable('/UserPlayedItems/$itemId'); } + @override + Future updatePlayedPosition( + String itemId, { + required int positionTicks, + }) async { + // The progress sync pushes this to the server on reconnect. + final serverId = _catalog.byId(itemId)?.row.serverId; + if (serverId == null || serverId.isEmpty) { + throw offlineUnavailable('/UserItems/$itemId/UserData'); + } + await _offlineRepo.updatePlaybackPosition(itemId, positionTicks); + } + @override Future updateUserRating(String itemId, {required bool likes}) async { await _queueRating( diff --git a/lib/data/services/book_reader_service.dart b/lib/data/services/book_reader_service.dart index bd6608949d..ef68bfb861 100644 --- a/lib/data/services/book_reader_service.dart +++ b/lib/data/services/book_reader_service.dart @@ -74,6 +74,26 @@ class BookReaderService { return extension != null && supportedExtensions.contains(extension); } + /// Comic and PDF positions on the server are the zero-based page index + /// times this, the same scale jellyfin-web uses. + static const int ticksPerPage = 10000; + + /// The zero-based page [item]'s server position points at, or null when it + /// has none or this device saved its own page more recently. + static int? newerServerPageIndex( + AggregatedItem item, { + DateTime? localSavedAt, + }) { + final ticks = item.playbackPositionTicks ?? 0; + if (ticks <= 0) return null; + final serverSavedAt = item.lastPlayedDate; + if (localSavedAt != null && + (serverSavedAt == null || !serverSavedAt.isAfter(localSavedAt))) { + return null; + } + return (ticks / ticksPerPage).round(); + } + static List buildDownloadUris(MediaServerClient client, AggregatedItem item) { final itemId = Uri.encodeComponent(item.id); final sourceId = _firstMediaSourceId(item); diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 287fa0689f..490ab68912 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -111,6 +111,7 @@ class _BookReaderScreenState extends State bool _epubScrollToBottomOnLoad = false; Timer? _comicStateSaveDebounce; Timer? _pdfPageSaveDebounce; + int? _reportedPageIndex; List<_BookmarkEntry> _bookmarks = const []; List<_HighlightEntry> _highlights = const []; List<({String title, int chapterIndex, int depth})> _epubTocEntries = @@ -282,6 +283,10 @@ class _BookReaderScreenState extends State return 'book_reader_pdf_${item.serverId}_${item.id}_page'; } + String get _pdfSavedAtPrefKey => '${_pdfProgressPrefKey}_saved_at'; + + String get _comicSavedAtPrefKey => '${_comicProgressKeyPrefix}_saved_at'; + String get _bookmarkPrefKey { final item = _item; if (item == null) return 'book_reader_bookmarks_unknown'; @@ -304,6 +309,18 @@ class _BookReaderScreenState extends State GetIt.instance(); } + int? _newerServerPageIndex(SharedPreferences prefs, String savedAtKey) { + final item = _item; + if (item == null) return null; + final savedAtMs = prefs.getInt(savedAtKey); + return BookReaderService.newerServerPageIndex( + item, + localSavedAt: savedAtMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(savedAtMs), + ); + } + @override void initState() { super.initState(); @@ -333,6 +350,39 @@ class _BookReaderScreenState extends State } } + Future _reportServerProgress() async { + final item = _item; + final (pageIndex, lastVisibleIndex, pageCount) = switch (_mode) { + _ReaderMode.pdf => ( + _currentPdfPage - 1, + _currentPdfPage - 1, + _pdfPageCount, + ), + _ReaderMode.comic => ( + _currentComicPage, + _currentComicPage + (_twoPageSpreadActive ? 1 : 0), + _comicPageCount, + ), + _ => (0, 0, 0), + }; + if (item == null || pageCount <= 0 || pageIndex == _reportedPageIndex) { + return; + } + final api = _resolveClient().userLibraryApi; + try { + // The position goes first so it still saves offline, where markPlayed + // throws. + await api.updatePlayedPosition( + item.id, + positionTicks: pageIndex * BookReaderService.ticksPerPage, + ); + if ((lastVisibleIndex + 1) / pageCount >= 0.98) { + await api.markPlayed(item.id); + } + _reportedPageIndex = pageIndex; + } catch (_) {} + } + Future _loadAndPrepare() async { await _loadItem(); if (_item != null && _error == null) { @@ -1441,7 +1491,9 @@ class _BookReaderScreenState extends State final initialPage = (widget.initialMode == 'comic' && widget.initialPosition != null) ? widget.initialPosition! - : (prefs.getInt('${_comicProgressKeyPrefix}_page') ?? 0); + : _newerServerPageIndex(prefs, _comicSavedAtPrefKey) ?? + prefs.getInt('${_comicProgressKeyPrefix}_page') ?? + 0; final savedPage = initialPage; final savedZoom = prefs.getDouble('${_comicProgressKeyPrefix}_zoom') ?? 1.0; @@ -1454,6 +1506,7 @@ class _BookReaderScreenState extends State setState(() { _currentComicPage = clampedPage; }); + _reportedPageIndex = clampedPage; _setComicZoom(savedZoom); @@ -1474,8 +1527,19 @@ class _BookReaderScreenState extends State } final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('${_comicProgressKeyPrefix}_page', _currentComicPage); + final pageKey = '${_comicProgressKeyPrefix}_page'; + // Only a page change updates the save time, so a zoom can't outrank a + // newer page from another device. + if (prefs.getInt(pageKey) != _currentComicPage) { + await prefs.setInt( + _comicSavedAtPrefKey, + DateTime.now().millisecondsSinceEpoch, + ); + } + await prefs.setInt(pageKey, _currentComicPage); await prefs.setDouble('${_comicProgressKeyPrefix}_zoom', _comicZoom); + + await _reportServerProgress(); } void _scheduleComicStateSave() { @@ -2854,12 +2918,19 @@ class _BookReaderScreenState extends State } final pageCount = controller.pageCount; final prefs = await SharedPreferences.getInstance(); + final serverPageIndex = _newerServerPageIndex( + prefs, + _pdfSavedAtPrefKey, + ); final initialPage = (widget.initialMode == 'pdf' && widget.initialPosition != null) ? widget.initialPosition! + : serverPageIndex != null + ? serverPageIndex + 1 : (prefs.getInt(_pdfProgressPrefKey) ?? 1); final savedPage = initialPage.clamp(1, pageCount); + _reportedPageIndex = savedPage - 1; final outline = await document.loadOutline(); if (mounted) { final flat = _flattenPdfOutline(outline, 0); @@ -2885,7 +2956,14 @@ class _BookReaderScreenState extends State const Duration(milliseconds: 300), () async { final prefs = await SharedPreferences.getInstance(); + if (prefs.getInt(_pdfProgressPrefKey) != pageNumber) { + await prefs.setInt( + _pdfSavedAtPrefKey, + DateTime.now().millisecondsSinceEpoch, + ); + } await prefs.setInt(_pdfProgressPrefKey, pageNumber); + await _reportServerProgress(); }, ); }, diff --git a/packages/server_core/lib/src/api/user_library_api.dart b/packages/server_core/lib/src/api/user_library_api.dart index af7f31de19..36a7c69b30 100644 --- a/packages/server_core/lib/src/api/user_library_api.dart +++ b/packages/server_core/lib/src/api/user_library_api.dart @@ -4,6 +4,9 @@ abstract class UserLibraryApi { Future unmarkFavorite(String itemId); Future markPlayed(String itemId); Future unmarkPlayed(String itemId); + /// Saves a resume position outside a playback session, stamped with the + /// current time so another device can tell which save is newer. + Future updatePlayedPosition(String itemId, {required int positionTicks}); Future updateUserRating(String itemId, {required bool likes}); Future updateNumericUserRating(String itemId, {required double rating}); Future deleteUserRating(String itemId); diff --git a/packages/server_emby/lib/src/api/emby_user_library_api.dart b/packages/server_emby/lib/src/api/emby_user_library_api.dart index 22a1f52153..aca0f5c9f0 100644 --- a/packages/server_emby/lib/src/api/emby_user_library_api.dart +++ b/packages/server_emby/lib/src/api/emby_user_library_api.dart @@ -34,6 +34,18 @@ class EmbyUserLibraryApi implements UserLibraryApi { await _dio.delete('/Users/$userId/PlayedItems/$itemId'); } + @override + Future updatePlayedPosition( + String itemId, { + required int positionTicks, + }) async { + final userId = _getUserId(); + await _dio.post('/Users/$userId/Items/$itemId/UserData', data: { + 'PlaybackPositionTicks': positionTicks, + 'LastPlayedDate': DateTime.now().toUtc().toIso8601String(), + }); + } + // Emby's own rating routes answer 200 and store nothing, and it leaves the // rating out of the item it hands back, so a rating written through them is // lost. These go through Moonbase, which writes the user data store the diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart index c94086e52d..5333a4c724 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart +++ b/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart @@ -29,6 +29,17 @@ class JellyfinUserLibraryApi implements UserLibraryApi { await _dio.delete('/UserPlayedItems/$itemId'); } + @override + Future updatePlayedPosition( + String itemId, { + required int positionTicks, + }) async { + await _dio.post('/UserItems/$itemId/UserData', data: { + 'PlaybackPositionTicks': positionTicks, + 'LastPlayedDate': DateTime.now().toUtc().toIso8601String(), + }); + } + @override Future updateUserRating(String itemId, {required bool likes}) async { await _dio.post('/UserItems/$itemId/Rating', queryParameters: { diff --git a/test/data/services/book_reader_service_test.dart b/test/data/services/book_reader_service_test.dart index 69c45ed456..d3e3ff8c73 100644 --- a/test/data/services/book_reader_service_test.dart +++ b/test/data/services/book_reader_service_test.dart @@ -20,6 +20,52 @@ void main() { expect(BookReaderService.detectExtension(item), 'epub'); }); + group('newerServerPageIndex', () { + AggregatedItem progress(int ticks, [String? lastPlayed]) => _book({ + 'UserData': { + 'PlaybackPositionTicks': ticks, + 'LastPlayedDate': ?lastPlayed, + }, + }); + final localSave = DateTime.utc(2026, 10, 17, 12); + + test('reads the page index on the jellyfin-web scale', () { + expect(BookReaderService.newerServerPageIndex(progress(30000)), 3); + }); + + test('a book with no server position has no server page', () { + expect(BookReaderService.newerServerPageIndex(progress(0)), isNull); + }); + + test('a later save on another device wins, even going backwards', () { + final item = progress(20000, '2026-10-17T12:05:00.0000000Z'); + + expect( + BookReaderService.newerServerPageIndex(item, localSavedAt: localSave), + 2, + ); + }); + + test("this device's later save wins over an older server position", () { + final item = progress(90000, '2026-10-17T11:55:00.0000000Z'); + + expect( + BookReaderService.newerServerPageIndex(item, localSavedAt: localSave), + isNull, + ); + }); + + test('an undated server position loses to a dated local save', () { + expect( + BookReaderService.newerServerPageIndex( + progress(90000), + localSavedAt: localSave, + ), + isNull, + ); + }); + }); + test('falls back to the item name when there is no path', () { final item = _book({'Path': ' ', 'Name': 'Dune'}); diff --git a/test/offline/pending_rating_sync_test.dart b/test/offline/pending_rating_sync_test.dart index 4c526255e3..8d00192f59 100644 --- a/test/offline/pending_rating_sync_test.dart +++ b/test/offline/pending_rating_sync_test.dart @@ -143,6 +143,19 @@ void main() { ); expect(store.all, isEmpty); }); + + test('a reader position lands on the row for the reconnect sync', + () async { + await api.updatePlayedPosition('m1', positionTicks: 30000); + + final row = await repo.getItem('m1'); + expect(row!.playbackPositionTicks, 30000); + expect(row.progressSynced, isFalse); + await expectLater( + api.updatePlayedPosition('missing', positionTicks: 30000), + throwsA(anything), + ); + }); }); group('SyncService.syncPendingRatings', () {