Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/data/offline/offline_stub_apis.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ class OfflineUserLibraryApi implements UserLibraryApi {
throw offlineUnavailable('/UserPlayedItems/$itemId');
}

@override
Future<void> 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<void> updateUserRating(String itemId, {required bool likes}) async {
await _queueRating(
Expand Down
20 changes: 20 additions & 0 deletions lib/data/services/book_reader_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uri> buildDownloadUris(MediaServerClient client, AggregatedItem item) {
final itemId = Uri.encodeComponent(item.id);
final sourceId = _firstMediaSourceId(item);
Expand Down
82 changes: 80 additions & 2 deletions lib/ui/screens/playback/book_reader_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class _BookReaderScreenState extends State<BookReaderScreen>
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 =
Expand Down Expand Up @@ -282,6 +283,10 @@ class _BookReaderScreenState extends State<BookReaderScreen>
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';
Expand All @@ -304,6 +309,18 @@ class _BookReaderScreenState extends State<BookReaderScreen>
GetIt.instance<MediaServerClient>();
}

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();
Expand Down Expand Up @@ -333,6 +350,39 @@ class _BookReaderScreenState extends State<BookReaderScreen>
}
}

Future<void> _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<void> _loadAndPrepare() async {
await _loadItem();
if (_item != null && _error == null) {
Expand Down Expand Up @@ -1441,7 +1491,9 @@ class _BookReaderScreenState extends State<BookReaderScreen>
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;

Expand All @@ -1454,6 +1506,7 @@ class _BookReaderScreenState extends State<BookReaderScreen>
setState(() {
_currentComicPage = clampedPage;
});
_reportedPageIndex = clampedPage;

_setComicZoom(savedZoom);

Expand All @@ -1474,8 +1527,19 @@ class _BookReaderScreenState extends State<BookReaderScreen>
}

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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

void _scheduleComicStateSave() {
Expand Down Expand Up @@ -2854,12 +2918,19 @@ class _BookReaderScreenState extends State<BookReaderScreen>
}
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);
Expand All @@ -2885,7 +2956,14 @@ class _BookReaderScreenState extends State<BookReaderScreen>
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();
},
);
},
Expand Down
3 changes: 3 additions & 0 deletions packages/server_core/lib/src/api/user_library_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ abstract class UserLibraryApi {
Future<void> unmarkFavorite(String itemId);
Future<void> markPlayed(String itemId);
Future<void> 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<void> updatePlayedPosition(String itemId, {required int positionTicks});
Future<void> updateUserRating(String itemId, {required bool likes});
Future<void> updateNumericUserRating(String itemId, {required double rating});
Future<void> deleteUserRating(String itemId);
Expand Down
12 changes: 12 additions & 0 deletions packages/server_emby/lib/src/api/emby_user_library_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ class EmbyUserLibraryApi implements UserLibraryApi {
await _dio.delete('/Users/$userId/PlayedItems/$itemId');
}

@override
Future<void> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ class JellyfinUserLibraryApi implements UserLibraryApi {
await _dio.delete('/UserPlayedItems/$itemId');
}

@override
Future<void> updatePlayedPosition(
String itemId, {
required int positionTicks,
}) async {
await _dio.post('/UserItems/$itemId/UserData', data: {
'PlaybackPositionTicks': positionTicks,
'LastPlayedDate': DateTime.now().toUtc().toIso8601String(),
});
}

@override
Future<void> updateUserRating(String itemId, {required bool likes}) async {
await _dio.post('/UserItems/$itemId/Rating', queryParameters: {
Expand Down
46 changes: 46 additions & 0 deletions test/data/services/book_reader_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'});

Expand Down
13 changes: 13 additions & 0 deletions test/offline/pending_rating_sync_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading