From d93ea9813fe0f8f8901254774b62f0749de8e2ae Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 29 May 2026 19:49:46 +0200 Subject: [PATCH] fix(playback): harden offline source reporting --- lib/database/app_database.dart | 148 ++++++++++++------ lib/database/app_database.g.dart | 83 +++++++++- lib/database/download_operations.dart | 8 + lib/database/tables.dart | 1 + lib/main.dart | 9 +- lib/media/download_resolution.dart | 3 +- lib/media/media_server_client.dart | 10 +- lib/media/playback_report_metadata.dart | 23 +++ lib/providers/download_provider.dart | 49 +++--- .../watch_state_overlay_provider.dart | 57 ++++--- .../parts/episode_navigation.dart | 22 +-- .../video_player/parts/playback_prompts.dart | 2 +- .../video_player/parts/playback_services.dart | 5 +- .../video_player/parts/playback_start.dart | 46 +++--- lib/screens/video_player_screen.dart | 31 ++-- lib/services/download_manager_service.dart | 11 ++ lib/services/external_player_service.dart | 61 +++++--- lib/services/jellyfin_client.dart | 1 + .../parts/images_downloads.dart | 2 +- .../jellyfin_client/parts/playback.dart | 4 +- lib/services/offline_watch_sync_service.dart | 66 +++----- lib/services/playback_context.dart | 31 ++++ .../playback_initialization_service.dart | 32 +++- lib/services/playback_progress_tracker.dart | 16 ++ lib/services/playback_report_session.dart | 16 ++ lib/services/playback_source_resolver.dart | 79 ++++++++++ lib/services/plex_client.dart | 25 ++- lib/services/watch_state_resolver.dart | 85 ++++++++++ lib/utils/video_player_navigation.dart | 10 +- lib/widgets/media_context_menu.dart | 19 ++- test/services/live_session_tracker_test.dart | 5 +- .../offline_watch_sync_service_test.dart | 30 ++-- .../playback_progress_tracker_test.dart | 17 +- .../playback_report_session_test.dart | 5 +- 34 files changed, 739 insertions(+), 273 deletions(-) create mode 100644 lib/media/playback_report_metadata.dart create mode 100644 lib/services/playback_context.dart create mode 100644 lib/services/playback_source_resolver.dart create mode 100644 lib/services/watch_state_resolver.dart diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index b25629b9..2097e3e2 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -60,7 +60,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.e); @override - int get schemaVersion => 14; + int get schemaVersion => 15; @override MigrationStrategy get migration { @@ -206,6 +206,13 @@ class AppDatabase extends _$AppDatabase { () => m.create(idxOfflineWatchProgressProfile), ); } + if (from < 15) { + appLogger.i('Adding mediaSourceId column to DownloadedMedia (v15 migration)'); + await _ignoreAlreadyExists( + 'DownloadedMedia.mediaSourceId column', + () => m.addColumn(downloadedMedia, downloadedMedia.mediaSourceId), + ); + } }, ); } @@ -282,6 +289,52 @@ class AppDatabase extends _$AppDatabase { .getSingleOrNull(); } + Future> getWatchActionsForKey( + String globalKey, { + String? profileId, + bool filterProfile = false, + String? clientScopeId, + bool filterClientScope = false, + }) { + return (select(offlineWatchProgress) + ..where( + (t) => + t.globalKey.equals(globalKey) & + (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) & + (filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)), + ) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + .get(); + } + + Future>> getWatchActionsForKeys( + Set globalKeys, { + String? profileId, + bool filterProfile = false, + Map? clientScopeIdsByGlobalKey, + }) async { + if (globalKeys.isEmpty) return const {}; + final rows = + await (select(offlineWatchProgress) + ..where( + (t) => + t.globalKey.isIn(globalKeys) & + (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)), + ) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + .get(); + + final result = >{}; + for (final action in rows) { + if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) { + final expectedScope = clientScopeIdsByGlobalKey[action.globalKey]; + if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue; + } + result.putIfAbsent(action.globalKey, () => []).add(action); + } + return result; + } + /// Get the latest actions for multiple items in a single query /// /// Returns a map of globalKey -> latest action for each key. @@ -338,49 +391,53 @@ class AppDatabase extends _$AppDatabase { final globalKey = buildGlobalKey(serverId, ratingKey); final now = DateTime.now().millisecondsSinceEpoch; - // Check for existing progress entry - final existing = - await (select(offlineWatchProgress) - ..where( - (t) => - t.globalKey.equals(globalKey) & - _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId) & - t.actionType.equals(OfflineActionType.progress.id), - ) - ..limit(1)) - .getSingleOrNull(); + await transaction(() async { + final existing = + await (select(offlineWatchProgress) + ..where( + (t) => + t.globalKey.equals(globalKey) & + _nullableTextPredicate(t.profileId, profileId) & + _clientScopePredicate(t.clientScopeId, clientScopeId) & + t.actionType.equals(OfflineActionType.progress.id), + ) + ..orderBy([(t) => OrderingTerm.asc(t.id)])) + .get(); - if (existing != null) { - // Update existing progress entry - await (update(offlineWatchProgress)..where((t) => t.id.equals(existing.id))).write( - OfflineWatchProgressCompanion( - viewOffset: Value(viewOffset), - duration: Value(duration), - shouldMarkWatched: Value(shouldMarkWatched), - profileId: Value(profileId), - clientScopeId: Value(clientScopeId), - updatedAt: Value(now), - ), - ); - } else { - // Insert new progress entry - await into(offlineWatchProgress).insert( - OfflineWatchProgressCompanion.insert( - serverId: serverId, - profileId: Value(profileId), - clientScopeId: Value(clientScopeId), - ratingKey: ratingKey, - globalKey: globalKey, - actionType: OfflineActionType.progress.id, - viewOffset: Value(viewOffset), - duration: Value(duration), - shouldMarkWatched: Value(shouldMarkWatched), - createdAt: now, - updatedAt: now, - ), - ); - } + final keep = existing.isEmpty ? null : existing.first; + if (keep != null) { + await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write( + OfflineWatchProgressCompanion( + viewOffset: Value(viewOffset), + duration: Value(duration), + shouldMarkWatched: Value(shouldMarkWatched), + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), + updatedAt: Value(now), + ), + ); + final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false); + if (duplicateIds.isNotEmpty) { + await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go(); + } + } else { + await into(offlineWatchProgress).insert( + OfflineWatchProgressCompanion.insert( + serverId: serverId, + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), + ratingKey: ratingKey, + globalKey: globalKey, + actionType: OfflineActionType.progress.id, + viewOffset: Value(viewOffset), + duration: Value(duration), + shouldMarkWatched: Value(shouldMarkWatched), + createdAt: now, + updatedAt: now, + ), + ); + } + }); } /// Insert a manual watch action (watched or unwatched). @@ -436,11 +493,14 @@ class AppDatabase extends _$AppDatabase { } /// Get count of pending sync items - Future getPendingSyncCount({String? profileId}) async { + Future getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async { final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]); if (profileId != null) { query.where(offlineWatchProgress.profileId.equals(profileId)); } + if (maxSyncAttempts != null) { + query.where(offlineWatchProgress.syncAttempts.isSmallerThanValue(maxSyncAttempts)); + } final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle(); return count ?? 0; } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 28f57d4b..ae9fcca1 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -221,6 +221,17 @@ class $DownloadedMediaTable extends DownloadedMedia requiredDuringInsert: false, defaultValue: const Constant(0), ); + static const VerificationMeta _mediaSourceIdMeta = const VerificationMeta( + 'mediaSourceId', + ); + @override + late final GeneratedColumn mediaSourceId = GeneratedColumn( + 'media_source_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -242,6 +253,7 @@ class $DownloadedMediaTable extends DownloadedMedia retryCount, bgTaskId, mediaIndex, + mediaSourceId, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -397,6 +409,15 @@ class $DownloadedMediaTable extends DownloadedMedia mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta), ); } + if (data.containsKey('media_source_id')) { + context.handle( + _mediaSourceIdMeta, + mediaSourceId.isAcceptableOrUnknown( + data['media_source_id']!, + _mediaSourceIdMeta, + ), + ); + } return context; } @@ -482,6 +503,10 @@ class $DownloadedMediaTable extends DownloadedMedia DriftSqlType.int, data['${effectivePrefix}media_index'], )!, + mediaSourceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}media_source_id'], + ), ); } @@ -512,6 +537,7 @@ class DownloadedMediaItem extends DataClass final int retryCount; final String? bgTaskId; final int mediaIndex; + final String? mediaSourceId; const DownloadedMediaItem({ required this.id, required this.serverId, @@ -532,6 +558,7 @@ class DownloadedMediaItem extends DataClass required this.retryCount, this.bgTaskId, required this.mediaIndex, + this.mediaSourceId, }); @override Map toColumns(bool nullToAbsent) { @@ -573,6 +600,9 @@ class DownloadedMediaItem extends DataClass map['bg_task_id'] = Variable(bgTaskId); } map['media_index'] = Variable(mediaIndex); + if (!nullToAbsent || mediaSourceId != null) { + map['media_source_id'] = Variable(mediaSourceId); + } return map; } @@ -615,6 +645,9 @@ class DownloadedMediaItem extends DataClass ? const Value.absent() : Value(bgTaskId), mediaIndex: Value(mediaIndex), + mediaSourceId: mediaSourceId == null && nullToAbsent + ? const Value.absent() + : Value(mediaSourceId), ); } @@ -645,6 +678,7 @@ class DownloadedMediaItem extends DataClass retryCount: serializer.fromJson(json['retryCount']), bgTaskId: serializer.fromJson(json['bgTaskId']), mediaIndex: serializer.fromJson(json['mediaIndex']), + mediaSourceId: serializer.fromJson(json['mediaSourceId']), ); } @override @@ -670,6 +704,7 @@ class DownloadedMediaItem extends DataClass 'retryCount': serializer.toJson(retryCount), 'bgTaskId': serializer.toJson(bgTaskId), 'mediaIndex': serializer.toJson(mediaIndex), + 'mediaSourceId': serializer.toJson(mediaSourceId), }; } @@ -693,6 +728,7 @@ class DownloadedMediaItem extends DataClass int? retryCount, Value bgTaskId = const Value.absent(), int? mediaIndex, + Value mediaSourceId = const Value.absent(), }) => DownloadedMediaItem( id: id ?? this.id, serverId: serverId ?? this.serverId, @@ -721,6 +757,9 @@ class DownloadedMediaItem extends DataClass retryCount: retryCount ?? this.retryCount, bgTaskId: bgTaskId.present ? bgTaskId.value : this.bgTaskId, mediaIndex: mediaIndex ?? this.mediaIndex, + mediaSourceId: mediaSourceId.present + ? mediaSourceId.value + : this.mediaSourceId, ); DownloadedMediaItem copyWithCompanion(DownloadedMediaCompanion data) { return DownloadedMediaItem( @@ -763,6 +802,9 @@ class DownloadedMediaItem extends DataClass mediaIndex: data.mediaIndex.present ? data.mediaIndex.value : this.mediaIndex, + mediaSourceId: data.mediaSourceId.present + ? data.mediaSourceId.value + : this.mediaSourceId, ); } @@ -787,7 +829,8 @@ class DownloadedMediaItem extends DataClass ..write('errorMessage: $errorMessage, ') ..write('retryCount: $retryCount, ') ..write('bgTaskId: $bgTaskId, ') - ..write('mediaIndex: $mediaIndex') + ..write('mediaIndex: $mediaIndex, ') + ..write('mediaSourceId: $mediaSourceId') ..write(')')) .toString(); } @@ -813,6 +856,7 @@ class DownloadedMediaItem extends DataClass retryCount, bgTaskId, mediaIndex, + mediaSourceId, ); @override bool operator ==(Object other) => @@ -836,7 +880,8 @@ class DownloadedMediaItem extends DataClass other.errorMessage == this.errorMessage && other.retryCount == this.retryCount && other.bgTaskId == this.bgTaskId && - other.mediaIndex == this.mediaIndex); + other.mediaIndex == this.mediaIndex && + other.mediaSourceId == this.mediaSourceId); } class DownloadedMediaCompanion extends UpdateCompanion { @@ -859,6 +904,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { final Value retryCount; final Value bgTaskId; final Value mediaIndex; + final Value mediaSourceId; const DownloadedMediaCompanion({ this.id = const Value.absent(), this.serverId = const Value.absent(), @@ -879,6 +925,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { this.retryCount = const Value.absent(), this.bgTaskId = const Value.absent(), this.mediaIndex = const Value.absent(), + this.mediaSourceId = const Value.absent(), }); DownloadedMediaCompanion.insert({ this.id = const Value.absent(), @@ -900,6 +947,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { this.retryCount = const Value.absent(), this.bgTaskId = const Value.absent(), this.mediaIndex = const Value.absent(), + this.mediaSourceId = const Value.absent(), }) : serverId = Value(serverId), ratingKey = Value(ratingKey), globalKey = Value(globalKey), @@ -925,6 +973,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { Expression? retryCount, Expression? bgTaskId, Expression? mediaIndex, + Expression? mediaSourceId, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -947,6 +996,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { if (retryCount != null) 'retry_count': retryCount, if (bgTaskId != null) 'bg_task_id': bgTaskId, if (mediaIndex != null) 'media_index': mediaIndex, + if (mediaSourceId != null) 'media_source_id': mediaSourceId, }); } @@ -970,6 +1020,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { Value? retryCount, Value? bgTaskId, Value? mediaIndex, + Value? mediaSourceId, }) { return DownloadedMediaCompanion( id: id ?? this.id, @@ -991,6 +1042,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { retryCount: retryCount ?? this.retryCount, bgTaskId: bgTaskId ?? this.bgTaskId, mediaIndex: mediaIndex ?? this.mediaIndex, + mediaSourceId: mediaSourceId ?? this.mediaSourceId, ); } @@ -1056,6 +1108,9 @@ class DownloadedMediaCompanion extends UpdateCompanion { if (mediaIndex.present) { map['media_index'] = Variable(mediaIndex.value); } + if (mediaSourceId.present) { + map['media_source_id'] = Variable(mediaSourceId.value); + } return map; } @@ -1080,7 +1135,8 @@ class DownloadedMediaCompanion extends UpdateCompanion { ..write('errorMessage: $errorMessage, ') ..write('retryCount: $retryCount, ') ..write('bgTaskId: $bgTaskId, ') - ..write('mediaIndex: $mediaIndex') + ..write('mediaIndex: $mediaIndex, ') + ..write('mediaSourceId: $mediaSourceId') ..write(')')) .toString(); } @@ -5319,6 +5375,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder = Value retryCount, Value bgTaskId, Value mediaIndex, + Value mediaSourceId, }); typedef $$DownloadedMediaTableUpdateCompanionBuilder = DownloadedMediaCompanion Function({ @@ -5341,6 +5398,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder = Value retryCount, Value bgTaskId, Value mediaIndex, + Value mediaSourceId, }); class $$DownloadedMediaTableFilterComposer @@ -5446,6 +5504,11 @@ class $$DownloadedMediaTableFilterComposer column: $table.mediaIndex, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get mediaSourceId => $composableBuilder( + column: $table.mediaSourceId, + builder: (column) => ColumnFilters(column), + ); } class $$DownloadedMediaTableOrderingComposer @@ -5551,6 +5614,11 @@ class $$DownloadedMediaTableOrderingComposer column: $table.mediaIndex, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get mediaSourceId => $composableBuilder( + column: $table.mediaSourceId, + builder: (column) => ColumnOrderings(column), + ); } class $$DownloadedMediaTableAnnotationComposer @@ -5638,6 +5706,11 @@ class $$DownloadedMediaTableAnnotationComposer column: $table.mediaIndex, builder: (column) => column, ); + + GeneratedColumn get mediaSourceId => $composableBuilder( + column: $table.mediaSourceId, + builder: (column) => column, + ); } class $$DownloadedMediaTableTableManager @@ -5696,6 +5769,7 @@ class $$DownloadedMediaTableTableManager Value retryCount = const Value.absent(), Value bgTaskId = const Value.absent(), Value mediaIndex = const Value.absent(), + Value mediaSourceId = const Value.absent(), }) => DownloadedMediaCompanion( id: id, serverId: serverId, @@ -5716,6 +5790,7 @@ class $$DownloadedMediaTableTableManager retryCount: retryCount, bgTaskId: bgTaskId, mediaIndex: mediaIndex, + mediaSourceId: mediaSourceId, ), createCompanionCallback: ({ @@ -5738,6 +5813,7 @@ class $$DownloadedMediaTableTableManager Value retryCount = const Value.absent(), Value bgTaskId = const Value.absent(), Value mediaIndex = const Value.absent(), + Value mediaSourceId = const Value.absent(), }) => DownloadedMediaCompanion.insert( id: id, serverId: serverId, @@ -5758,6 +5834,7 @@ class $$DownloadedMediaTableTableManager retryCount: retryCount, bgTaskId: bgTaskId, mediaIndex: mediaIndex, + mediaSourceId: mediaSourceId, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index 82c957b0..d5374419 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -85,6 +85,7 @@ extension DownloadDatabaseOperations on AppDatabase { String? grandparentRatingKey, required int status, int mediaIndex = 0, + String? mediaSourceId, }) async { await into(downloadedMedia).insert( DownloadedMediaCompanion.insert( @@ -97,6 +98,7 @@ extension DownloadDatabaseOperations on AppDatabase { grandparentRatingKey: Value(grandparentRatingKey), status: status, mediaIndex: Value(mediaIndex), + mediaSourceId: Value(mediaSourceId), ), mode: InsertMode.insertOrReplace, ); @@ -145,6 +147,12 @@ extension DownloadDatabaseOperations on AppDatabase { )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status))); } + Future updateDownloadMediaSource(String globalKey, String? mediaSourceId) async { + await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( + DownloadedMediaCompanion(mediaSourceId: Value(mediaSourceId)), + ); + } + Future updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async { await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( DownloadedMediaCompanion( diff --git a/lib/database/tables.dart b/lib/database/tables.dart index ca9ca599..df3e14cb 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -59,6 +59,7 @@ class DownloadedMedia extends Table { IntColumn get retryCount => integer().withDefault(const Constant(0))(); TextColumn get bgTaskId => text().nullable()(); IntColumn get mediaIndex => integer().withDefault(const Constant(0))(); + TextColumn get mediaSourceId => text().nullable()(); } /// Profile ownership for shared physical downloads. diff --git a/lib/main.dart b/lib/main.dart index 91f618e9..36fd0105 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,6 +73,7 @@ import 'utils/media_server_http_client.dart'; import 'utils/orientation_helper.dart'; import 'utils/watch_state_notifier.dart'; import 'i18n/strings.g.dart'; +import 'media/media_server_client.dart'; import 'focus/input_mode_tracker.dart'; import 'focus/key_event_utils.dart'; import 'package:intl/date_symbol_data_local.dart'; @@ -764,11 +765,15 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, ), - ChangeNotifierProxyProvider( + ChangeNotifierProxyProvider2( create: (_) => WatchStateOverlayProvider(), - update: (_, activeProfile, previous) { + update: (_, activeProfile, multiServer, previous) { final provider = previous ?? WatchStateOverlayProvider(); provider.setActiveProfileId(activeProfile.activeId); + provider.setActiveClientScopesByServer({ + for (final serverId in multiServer.serverManager.serverIds) + serverId: multiServer.serverManager.getClient(serverId)?.cacheServerId, + }); return provider; }, ), diff --git a/lib/media/download_resolution.dart b/lib/media/download_resolution.dart index 09be1f87..d4ae0b5e 100644 --- a/lib/media/download_resolution.dart +++ b/lib/media/download_resolution.dart @@ -47,7 +47,8 @@ class DownloadArtworkSpec { /// version. class DownloadResolution { final String? videoUrl; + final String? mediaSourceId; final List externalSubtitles; - const DownloadResolution({required this.videoUrl, this.externalSubtitles = const []}); + const DownloadResolution({required this.videoUrl, this.mediaSourceId, this.externalSubtitles = const []}); } diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index acaa8d0a..0ea3b008 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -18,6 +18,7 @@ import 'media_item.dart'; import 'media_kind.dart'; import 'media_library.dart'; import 'media_playlist.dart'; +import 'playback_report_metadata.dart'; import 'server_capabilities.dart'; /// Backend-neutral client for a single media server (Plex or Jellyfin). @@ -493,18 +494,15 @@ abstract class MediaServerClient { }); /// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes - /// the session row. [offline] and [updatedAt] are used by Plex when replaying - /// queued offline watch progress; backends that have no equivalent may ignore - /// them. + /// the session row. [report] carries semantic metadata such as offline + /// replay timing without leaking backend-specific wire parameter names. Future reportPlaybackStopped({ required String itemId, required Duration position, Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }); /// Resolve the video URL, media info, and external subtitle list for diff --git a/lib/media/playback_report_metadata.dart b/lib/media/playback_report_metadata.dart new file mode 100644 index 00000000..b8b91f57 --- /dev/null +++ b/lib/media/playback_report_metadata.dart @@ -0,0 +1,23 @@ +/// Backend-neutral metadata attached to a playback report. +/// +/// This deliberately describes user/client intent rather than backend wire +/// parameters. Plex maps offline replays to `offline`, `updated`, and +/// `continuing` timeline query params; backends without equivalent semantics +/// can ignore the fields. +enum PlaybackReportOrigin { live, offlineReplay } + +class PlaybackReportMetadata { + final PlaybackReportOrigin origin; + final DateTime? recordedAt; + final bool? willContinue; + + const PlaybackReportMetadata({this.origin = PlaybackReportOrigin.live, this.recordedAt, this.willContinue}); + + const PlaybackReportMetadata.live({bool? willContinue}) + : this(origin: PlaybackReportOrigin.live, willContinue: willContinue); + + const PlaybackReportMetadata.offlineReplay({required DateTime recordedAt, bool willContinue = false}) + : this(origin: PlaybackReportOrigin.offlineReplay, recordedAt: recordedAt, willContinue: willContinue); + + bool get isOfflineReplay => origin == PlaybackReportOrigin.offlineReplay; +} diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index f1b2a614..6b1536bd 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -17,6 +17,7 @@ import '../services/download_storage_service.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; import '../services/storage_service.dart'; +import '../services/watch_state_resolver.dart'; import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; @@ -361,7 +362,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin scopes[key] = await _offlineWatchScopeForGlobalKey(key); } final profileId = _activeProfileId; - final actions = await _database.getLatestWatchActionsForKeys( + final actions = await _database.getWatchActionsForKeys( keys, profileId: profileId, filterProfile: profileId != null, @@ -371,22 +372,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin for (final entry in actions.entries) { final base = _metadata[entry.key]; if (base == null) continue; - final action = entry.value; - bool? isWatched; - int? viewOffsetMs; - switch (action.actionType) { - case 'watched': - isWatched = true; - viewOffsetMs = 0; - case 'unwatched': - isWatched = false; - viewOffsetMs = 0; - case 'progress': - isWatched = action.shouldMarkWatched; - viewOffsetMs = action.shouldMarkWatched ? 0 : action.viewOffset; - } - if (isWatched == null) continue; - _metadata[entry.key] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs); + final snapshot = WatchStateResolver.fromActions(entry.value); + if (snapshot.isEmpty) continue; + _metadata[entry.key] = snapshot.apply(base); } } catch (e) { appLogger.w('Failed to apply offline watch overlay', error: e); @@ -503,6 +491,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final globalKey = buildGlobalKey(event.serverId, event.itemId); final base = _metadata[globalKey]; if (base == null) return; + final eventScope = event.cacheServerId; + final activeScope = _downloadManager.activeClientScopeIdForServer(event.serverId); + if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) { + return; + } final isWatched = event.isNowWatched!; _metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: 0); @@ -829,7 +822,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Get the local video file path for a downloaded item /// Returns null if not downloaded or file doesn't exist - Future getVideoFilePath(String globalKey) async { + Future getVideoFilePath(String globalKey, {int? mediaIndex, String? mediaSourceId}) async { appLogger.d('getVideoFilePath called with globalKey: $globalKey'); if (!_ownsDownloadKey(globalKey)) { appLogger.w('Profile does not own downloaded item: $globalKey'); @@ -845,6 +838,26 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.w('Download not complete. Status: ${downloadedItem.status}'); return null; } + final expectedSourceId = mediaSourceId?.trim(); + final downloadedSourceId = downloadedItem.mediaSourceId; + if (expectedSourceId != null && + expectedSourceId.isNotEmpty && + downloadedSourceId != null && + downloadedSourceId.isNotEmpty && + expectedSourceId != downloadedSourceId) { + appLogger.w( + 'Downloaded media source mismatch for $globalKey: have $downloadedSourceId, expected $expectedSourceId', + ); + return null; + } + if ((downloadedSourceId == null || downloadedSourceId.isEmpty) && + mediaIndex != null && + downloadedItem.mediaIndex != mediaIndex) { + appLogger.w( + 'Downloaded media index mismatch for $globalKey: have ${downloadedItem.mediaIndex}, expected $mediaIndex', + ); + return null; + } if (downloadedItem.videoFilePath == null) { appLogger.w('Video file path is null for globalKey: $globalKey'); return null; diff --git a/lib/providers/watch_state_overlay_provider.dart b/lib/providers/watch_state_overlay_provider.dart index f8485e17..5f153bc2 100644 --- a/lib/providers/watch_state_overlay_provider.dart +++ b/lib/providers/watch_state_overlay_provider.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import '../media/media_item.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; +import '../services/watch_state_resolver.dart'; +import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; @immutable @@ -14,6 +16,12 @@ class WatchStateOverlayPatch { const WatchStateOverlayPatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs}); + factory WatchStateOverlayPatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStateOverlayPatch( + isWatched: snapshot.isWatched, + hasViewOffsetMs: snapshot.hasViewOffsetMs, + viewOffsetMs: snapshot.viewOffsetMs, + ); + @override bool operator ==(Object other) => identical(this, other) || @@ -38,8 +46,19 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti StreamSubscription? _subscription; final Map _patches = {}; String? _activeProfileId; + Map _activeClientScopesByServer = const {}; - WatchStateOverlayPatch? patchForGlobalKey(String globalKey) => _patches[globalKey]; + WatchStateOverlayPatch? patchForGlobalKey(String globalKey) { + final parsed = parseGlobalKey(globalKey); + if (parsed != null) { + final scoped = _activeClientScopesByServer[parsed.serverId]; + if (scoped != null && scoped.isNotEmpty) { + final scopedPatch = _patches[buildGlobalKey(scoped, parsed.ratingKey)]; + if (scopedPatch != null) return scopedPatch; + } + } + return _patches[globalKey]; + } WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey); @@ -69,29 +88,25 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti safeNotifyListeners(); } - void _onWatchStateEvent(WatchStateEvent event) { - final patch = switch (event.changeType) { - WatchStateChangeType.watched => const WatchStateOverlayPatch( - isWatched: true, - hasViewOffsetMs: true, - viewOffsetMs: 0, - ), - WatchStateChangeType.unwatched => const WatchStateOverlayPatch( - isWatched: false, - hasViewOffsetMs: true, - viewOffsetMs: 0, - ), - WatchStateChangeType.progressUpdate => - event.isNowWatched == true - ? const WatchStateOverlayPatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) - : WatchStateOverlayPatch(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset), - WatchStateChangeType.removedFromContinueWatching => null, + void setActiveClientScopesByServer(Map scopes) { + final normalized = { + for (final entry in scopes.entries) + if (entry.value != null && entry.value!.isNotEmpty && entry.value != entry.key) entry.key: entry.value, }; + if (mapEquals(_activeClientScopesByServer, normalized)) return; + _activeClientScopesByServer = Map.unmodifiable(normalized); + if (_patches.isNotEmpty) safeNotifyListeners(); + } - if (patch == null) return; + void _onWatchStateEvent(WatchStateEvent event) { + final patch = WatchStateOverlayPatch.fromSnapshot(WatchStateResolver.fromEvent(event)); - if (_patches[event.globalKey] == patch) return; - _patches[event.globalKey] = patch; + final cacheServerId = event.cacheServerId; + final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId + ? buildGlobalKey(cacheServerId, event.itemId) + : event.globalKey; + if (_patches[key] == patch) return; + _patches[key] = patch; safeNotifyListeners(); } diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index d4f29cae..002e8f54 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -135,6 +135,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { Future _swapEpisodeInPip(MediaItem episodeMetadata) async { _isSwappingEpisode = true; final currentPlayer = player!; + final playbackGeneration = _beginPlaybackGeneration(); final previousMetadata = _currentMetadata; final currentAudioTrack = currentPlayer.state.track.audio; @@ -146,13 +147,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // backend. We still narrow to [plexClient] for [TrackManager]'s // server-side track persistence, which is Plex-only — Jellyfin // sessions get a null `getPlexClient` and skip that path. - final mediaClient = _getOnlineMediaServerClient(context); - final plexClient = mediaClient is PlexClient ? mediaClient : null; - final streamHeaders = mediaClient?.streamHeaders; final offlineWatchService = context.read(); final userProfileProvider = context.read(); final playbackState = context.read(); final database = context.read(); + final serverManager = context.read().serverManager; await _sendStoppedProgressOnce(); _progressTracker?.stopTracking(); @@ -169,19 +168,21 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _hasFirstFrame.value = false; try { - // Same service shape works for both online (mediaClient non-null, - // bundled video URL + media info) and pure-offline (mediaClient null, - // local file + cached media info if available). - final playbackService = PlaybackInitializationService(client: mediaClient, database: database); - final result = await playbackService.getPlaybackData( + final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database); + final playbackContext = await playbackResolver.resolve( metadata: episodeMetadata, selectedMediaIndex: widget.selectedMediaIndex, - preferOffline: widget.isOffline || _selectedQualityPreset.isOriginal, + selectedMediaSourceId: widget.selectedMediaSourceId, + offlineLibraryMode: widget.isOffline, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, sessionIdentifier: _playbackSessionIdentifier, transcodeSessionId: _playbackTranscodeSessionId, ); + final result = playbackContext.result; + final mediaClient = playbackContext.reportingClient; + final plexClient = mediaClient is PlexClient ? mediaClient : null; + final streamHeaders = playbackContext.streamHeaders; if (result.videoUrl == null) { throw PlaybackException('No video URL available'); @@ -190,6 +191,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { Duration? resumePosition; _isTranscoding = result.isTranscoding; _effectiveIsOffline = result.isOffline; + _playbackContext = playbackContext; _playbackPlaySessionId = result.playSessionId; _playbackPlayMethod = result.playMethod; _selectedAudioStreamId = result.activeAudioStreamId; @@ -234,7 +236,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _completionTriggered = false; _isSwappingEpisode = false; - if (!mounted) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; _scrubPreviewSource?.dispose(); _setPlayerState(() { diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 0d5ee4d7..6481d1a5 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -88,7 +88,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _cancelAutoPlay() { _autoPlayTimer?.cancel(); - _stoppedProgressFuture = null; + _progressTracker?.resumeAfterStoppedReport(); _completionTriggered = false; // Reset so it can trigger again if user seeks near end _setPlayerState(() { _showPlayNextDialog = false; diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 0c4633ea..17bbabee 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -20,7 +20,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { }) { final currentPlayer = player; if (currentPlayer == null) return; - _stoppedProgressFuture = null; // Progress tracker — local media still reports live when its server is // online; only queue locally when no reporting client is reachable. @@ -30,7 +29,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { metadata: metadata, player: currentPlayer, offlineWatchService: offlineWatchService, - queueOnOnlineFailure: _usesLocalPlaybackSource, + queueOnOnlineFailure: _playbackContext?.shouldQueueOnReportFailure ?? _usesLocalPlaybackSource, playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'), playSessionId: playSessionId, mediaInfo: mediaInfo, @@ -82,7 +81,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Get a live reporting client when possible. Downloaded/local playback // still uses this path when the server is reachable. - final mediaClient = _getOnlineMediaServerClient(context); + final mediaClient = _playbackContext?.reportingClient ?? _getOnlineMediaServerClient(context); final offlineWatchService = context.read(); // Initialize media controls manager (must exist before the per-item diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index bb9d8e4a..0cb39cb4 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -4,6 +4,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { Future _startPlayback() async { final currentPlayer = player; if (!mounted || currentPlayer == null) return; + final playbackGeneration = _beginPlaybackGeneration(); // Live TV mode: bypass standard playback initialization if (widget.isLive) { @@ -11,7 +12,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _hasFirstFrame.value = false; await currentPlayer.requestAudioFocus(); await _setLiveStreamOptions(); - if (!mounted || player != currentPlayer) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; String streamUrl; if (_liveStreamUrl != null) { @@ -97,7 +98,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _livePlaybackStartTime = DateTime.now(); await currentPlayer.setProperty('force-seekable', 'no'); await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - if (!mounted || player != currentPlayer) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; _trackManager?.cacheExternalSubtitles(const []); @@ -128,30 +129,29 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { try { PlaybackInitializationResult result; + PlaybackContext playbackContext; Map? streamHeaders; if (widget.isOffline) { - // Offline mode: route through PlaybackInitializationService with a - // (possibly null) cached client. The service reads cached media - // info via the client when available, falls back to local file + - // sidecar subtitles otherwise. - final cachedSourceClient = _getOnlineMediaServerClient(context); - final offlineService = PlaybackInitializationService( - client: cachedSourceClient, + final playbackResolver = PlaybackSourceResolver( + serverManager: context.read().serverManager, database: context.read(), ); - result = await offlineService.getPlaybackData( + playbackContext = await playbackResolver.resolve( metadata: _currentMetadata, selectedMediaIndex: widget.selectedMediaIndex, selectedMediaSourceId: widget.selectedMediaSourceId, - preferOffline: true, + offlineLibraryMode: true, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, ); + result = playbackContext.result; if (result.videoUrl == null) { throw PlaybackException(t.messages.fileInfoNotAvailable); } - if (!result.usesLocalMedia) { - streamHeaders = cachedSourceClient?.streamHeaders; - } + streamHeaders = playbackContext.streamHeaders; _isTranscoding = result.isTranscoding; _effectiveIsOffline = result.isOffline; _playbackPlaySessionId = result.playSessionId; @@ -166,11 +166,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { if (playbackDataFuture == null) { throw StateError('Playback data was not prepared before playback start'); } - result = await playbackDataFuture; + playbackContext = await playbackDataFuture; + result = playbackContext.result; if (!mounted || player != currentPlayer) return; - if (result.usesLocalMedia) { - streamHeaders = null; - } + streamHeaders = playbackContext.streamHeaders; _isTranscoding = result.isTranscoding; _effectiveIsOffline = result.isOffline; @@ -186,12 +185,13 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _selectedQualityPreset = TranscodeQualityPreset.original; } } + _playbackContext = playbackContext; // Primary refresh-rate path: when metadata provides FPS, Android MPV can // switch before `loadfile`; ExoPlayer and MPV fallback cases still open // paused and switch before visible playback starts. final settingsService = await SettingsService.getInstance(); - if (!mounted || player != currentPlayer) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; final displayCriteria = result.mediaInfo?.displayCriteria; final preKnownFps = displayCriteria?.fps; final willAutoSwitch = @@ -268,7 +268,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { } else { await currentPlayer.requestAudioFocus(); } - if (!mounted || player != currentPlayer) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; // Pass resume position if available. // In offline mode, prefer locally tracked progress over the cached server value @@ -335,7 +335,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { timelineOffset: openTiming.timelineOffset, timelineDuration: openTiming.timelineDuration, ); - if (!mounted || player != currentPlayer) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; // Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale) // Must be called after open() since that's when ExoPlayer initializes @@ -394,7 +394,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { } await _initVideoFilterAndPip(); - if (!mounted || player != currentPlayer) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; if (player == currentPlayer) { // Auto-PiP: set up callback for API 26-30 path and initial state @@ -424,7 +424,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { await _restoreAmbientLighting(); } } - if (!mounted || player != currentPlayer) return; + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; // Track manager: owns track selection, external subtitle loading, and Plex // immediate stream writes. Jellyfin persists selected stream indexes through diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index fb266be4..1ab267f3 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -44,7 +44,9 @@ import '../services/episode_navigation_service.dart'; import '../services/app_foreground_service.dart'; import '../services/media_controls_manager.dart'; import '../services/playback_initialization_service.dart'; +import '../services/playback_context.dart'; import '../services/playback_progress_tracker.dart'; +import '../services/playback_source_resolver.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/display_mode_service.dart'; import '../services/settings_service.dart'; @@ -274,7 +276,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // the metadata fetch (and transcode-decision HTTP, if non-original preset) // overlaps with MPV property configuration. Awaited inside `_startPlayback` // immediately before `player.open()` needs the video URL. - Future? _playbackDataFuture; + Future? _playbackDataFuture; + PlaybackContext? _playbackContext; + int _playbackGeneration = 0; // HTTP headers attached to the player's `Media` request — `X-Plex-Token` // for Plex, empty for Jellyfin (token rides in the URL there). Sourced // from `MediaServerClient.streamHeaders` so the player code path stays @@ -380,7 +384,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin int _rewindOnResume = 0; Future _lifecycleTransition = Future.value(); String _playerBackendLabel = 'unknown'; - Future? _stoppedProgressFuture; Timer? _tvBackgroundMediaControlResumeTimer; /// Whether to skip lifecycle actions because PiP is active or about to start. @@ -434,6 +437,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time); + int _beginPlaybackGeneration() => ++_playbackGeneration; + + bool _isCurrentPlaybackGeneration(int generation, Player currentPlayer) { + return mounted && player == currentPlayer && _playbackGeneration == generation; + } + final ValueNotifier _isBuffering = ValueNotifier(false); final ValueNotifier _hasFirstFrame = ValueNotifier(false); final ValueNotifier _isExiting = ValueNotifier(false); @@ -636,15 +645,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin } else { _selectedQualityPreset = widget.selectedQualityPreset!; } - final playbackService = PlaybackInitializationService( - client: genericClient, + final playbackResolver = PlaybackSourceResolver( + serverManager: context.read().serverManager, database: context.read(), ); - _playbackDataFuture = playbackService.getPlaybackData( + _playbackDataFuture = playbackResolver.resolve( metadata: _currentMetadata, selectedMediaIndex: widget.selectedMediaIndex, selectedMediaSourceId: widget.selectedMediaSourceId, - preferOffline: _selectedQualityPreset.isOriginal, + offlineLibraryMode: false, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, sessionIdentifier: _playbackSessionIdentifier, @@ -1269,20 +1278,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin String get playbackTranscodeSessionId => _playbackTranscodeSessionId; Future _sendStoppedProgressOnce({Duration? positionOverride}) { - final existing = _stoppedProgressFuture; - if (existing != null) return existing; - final tracker = _progressTracker; if (tracker == null) return Future.value(); - final future = tracker.sendProgress('stopped', positionOverride: positionOverride).catchError(( - Object e, - StackTrace st, - ) { + return tracker.sendStoppedProgressOnce(positionOverride: positionOverride).catchError((Object e, StackTrace st) { appLogger.d('Stopped progress flush failed', error: e, stackTrace: st); }); - _stoppedProgressFuture = future; - return future; } /// Dispose the player before replacing the video to avoid race conditions diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index c3ff1bea..37efc03a 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1025,6 +1025,7 @@ class DownloadManagerService { grandparentRatingKey: metadata.grandparentId, status: DownloadStatus.queued.index, mediaIndex: mediaIndex, + mediaSourceId: _mediaSourceIdForIndex(metadata, mediaIndex), ); // Populate the offline cache via the read path and pin so the row @@ -1044,6 +1045,13 @@ class DownloadManagerService { unawaited(_processQueue(client)); } + String? _mediaSourceIdForIndex(MediaItem metadata, int mediaIndex) { + final versions = metadata.mediaVersions; + if (versions == null || mediaIndex < 0 || mediaIndex >= versions.length) return null; + final id = versions[mediaIndex].id.trim(); + return id.isEmpty ? null : id; + } + /// Process the download queue — prepares and enqueues items with background_downloader. /// Non-blocking: returns after all queued items are enqueued (downloads run natively). Future _processQueue(MediaServerClient client) async { @@ -1250,6 +1258,9 @@ class DownloadManagerService { resolution = await client.resolveDownload(metadata, mediaIndex: selectedMediaIndex); if (resolution.videoUrl == null) throw Exception('Could not get video URL for $globalKey'); } + if (resolution.mediaSourceId != null && resolution.mediaSourceId != existing.mediaSourceId) { + await _database.updateDownloadMediaSource(globalKey, resolution.mediaSourceId); + } if (await _isCancelledOrDeleted(globalKey)) { appLogger.d('Skipping enqueue for $globalKey: cancelled during preparation'); diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index 7d1614bd..ba956a90 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -12,6 +12,8 @@ import '../utils/snackbar_helper.dart'; import '../utils/watch_state_notifier.dart'; import '../i18n/strings.g.dart'; import 'settings_service.dart'; +import 'offline_watch_sync_service.dart'; +import 'playback_report_session.dart'; import 'trackers/tracker_coordinator.dart'; const _externalPlayerChannel = MethodChannel('com.plezy/external_player'); @@ -60,6 +62,7 @@ class ExternalPlayerService { required BuildContext context, MediaItem? metadata, MediaServerClient? client, + OfflineWatchSyncService? offlineWatchService, int mediaIndex = 0, String? mediaSourceId, String? videoUrl, @@ -93,11 +96,12 @@ class ExternalPlayerService { // On Android, always use native intent to avoid url_launcher opening in browser if (Platform.isAndroid && context.mounted) { final launchResult = await _launchAndroidNative(resolvedUrl, player, context, metadata: metadata); - if (launchResult.launched && metadata != null && client != null) { + if (launchResult.launched && metadata != null) { await _reportAndroidExternalProgress( launchResult, metadata: metadata, client: client, + offlineWatchService: offlineWatchService, mediaSourceId: mediaSourceId, ); } @@ -147,7 +151,8 @@ class ExternalPlayerService { static Future _reportAndroidExternalProgress( _ExternalPlayerLaunchResult result, { required MediaItem metadata, - required MediaServerClient client, + required MediaServerClient? client, + OfflineWatchSyncService? offlineWatchService, String? mediaSourceId, }) async { if (result.playbackError) { @@ -162,25 +167,28 @@ class ExternalPlayerService { final positionMs = durationMs == null ? reportedPositionMs : reportedPositionMs.clamp(0, durationMs).toInt(); final position = Duration(milliseconds: positionMs); final duration = durationMs == null ? null : Duration(milliseconds: durationMs); + if (client == null) { + await _queueExternalProgress(metadata, offlineWatchService, position: position, duration: duration); + return; + } try { - try { - await client.reportPlaybackStarted( - itemId: metadata.id, + final session = PlaybackReportSession(client: client, itemId: metadata.id, playMethod: 'DirectPlay'); + await session.report( + PlaybackReportSnapshot( + state: 'playing', position: position, - duration: duration, - playMethod: 'DirectPlay', - mediaSourceId: mediaSourceId, - ); - } catch (e) { - appLogger.d('External player progress: started call failed (continuing)', error: e); - } - - await client.reportPlaybackStopped( - itemId: metadata.id, - position: position, - duration: duration, - mediaSourceId: mediaSourceId, + duration: duration ?? position, + resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId), + ), + ); + await session.report( + PlaybackReportSnapshot( + state: 'stopped', + position: position, + duration: duration ?? position, + resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId), + ), ); if (duration == null) return; @@ -198,9 +206,26 @@ class ExternalPlayerService { } } catch (e) { appLogger.w('Failed to sync external player progress for ${metadata.id}', error: e); + await _queueExternalProgress(metadata, offlineWatchService, position: position, duration: duration); } } + static Future _queueExternalProgress( + MediaItem metadata, + OfflineWatchSyncService? offlineWatchService, { + required Duration position, + required Duration? duration, + }) async { + final serverId = metadata.serverId; + if (offlineWatchService == null || serverId == null || duration == null || duration.inMilliseconds <= 0) return; + await offlineWatchService.queueProgressUpdate( + serverId: serverId, + itemId: metadata.id, + viewOffset: position.inMilliseconds.clamp(0, duration.inMilliseconds).toInt(), + duration: duration.inMilliseconds, + ); + } + static int? _positive(int? value) => value != null && value > 0 ? value : null; /// Map known player IDs to their Android package names. diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 9c2a5af9..e53be5a8 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -21,6 +21,7 @@ import '../media/media_kind.dart'; import '../media/media_library.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; +import '../media/playback_report_metadata.dart'; import '../media/server_capabilities.dart'; import '../models/jellyfin/jellyfin_user_profile.dart'; import '../models/livetv_channel.dart'; diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index a49637bc..e48af401 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -116,7 +116,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { } } - return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles); + return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitles: subtitles); } Map? _selectDownloadMediaSource(List sources, String? selectedSourceId, int mediaIndex) { diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index ce497c5e..82fb4db6 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -647,9 +647,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { final response = await _http.post( '/Sessions/Playing/Stopped', diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 720940d4..cab0c773 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -9,6 +9,7 @@ import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; +import '../media/playback_report_metadata.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; import 'offline_mode_source.dart'; @@ -17,6 +18,7 @@ import 'multi_server_manager.dart'; import 'plex_client.dart'; import 'settings_service.dart'; import 'trackers/tracker_coordinator.dart'; +import 'watch_state_resolver.dart'; /// Service for managing offline watch progress and syncing it back to the /// owning server. Backend-neutral over [MediaServerClient] — Plex actions @@ -275,31 +277,19 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Returns: /// - `true` if item was marked as watched locally or progress >= server threshold /// - `false` if item was marked as unwatched locally - /// - `null` if no local action exists (use cached server data) + /// - `null` if no local watched/unwatched action exists (use cached server data) Future getLocalWatchStatus(String globalKey, {String? clientScopeId}) async { await _adoptLegacyWatchActionsForActiveProfile(); final expectedScope = clientScopeId ?? _activeClientScopeIdForGlobalKey(globalKey); final profileId = _activeProfileId; - final action = await _database.getLatestWatchAction( + final actions = await _database.getWatchActionsForKey( globalKey, profileId: profileId, filterProfile: profileId != null, clientScopeId: expectedScope, filterClientScope: expectedScope != null, ); - if (action == null) return null; - - switch (action.actionType) { - case 'watched': - return true; - case 'unwatched': - return false; - case 'progress': - // Check if progress exceeds threshold - return action.shouldMarkWatched; - default: - return null; - } + return WatchStateResolver.fromActions(actions).isWatched; } /// Get local watch statuses for multiple items in a single database query. @@ -315,7 +305,7 @@ class OfflineWatchSyncService extends ChangeNotifier { final scopes = clientScopeIdsByGlobalKey ?? _activeClientScopeIdsForGlobalKeys(globalKeys); final profileId = _activeProfileId; - final actions = await _database.getLatestWatchActionsForKeys( + final actions = await _database.getWatchActionsForKeys( globalKeys, profileId: profileId, filterProfile: profileId != null, @@ -324,22 +314,7 @@ class OfflineWatchSyncService extends ChangeNotifier { final result = {}; for (final key in globalKeys) { - final action = actions[key]; - if (action == null) { - result[key] = null; - continue; - } - - switch (action.actionType) { - case 'watched': - result[key] = true; - case 'unwatched': - result[key] = false; - case 'progress': - result[key] = action.shouldMarkWatched; - default: - result[key] = null; - } + result[key] = WatchStateResolver.fromActions(actions[key] ?? const []).isWatched; } return result; @@ -347,35 +322,30 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Get the local view offset (resume position) for a media item. /// - /// Returns the locally tracked position, or null if none exists. + /// Returns the locally tracked position, or null if none exists. Explicit + /// watched/unwatched actions clear resume by resolving to a zero offset. Future getLocalViewOffset(String globalKey, {String? clientScopeId}) async { await _adoptLegacyWatchActionsForActiveProfile(); final expectedScope = clientScopeId ?? _activeClientScopeIdForGlobalKey(globalKey); final profileId = _activeProfileId; - final action = await _database.getLatestWatchAction( + final actions = await _database.getWatchActionsForKey( globalKey, profileId: profileId, filterProfile: profileId != null, clientScopeId: expectedScope, filterClientScope: expectedScope != null, ); - if (action == null) return null; - - // Only return offset for progress actions - if (action.actionType == OfflineActionType.progress.id) { - if (action.shouldMarkWatched) return null; - return action.viewOffset; - } - - return null; + final snapshot = WatchStateResolver.fromActions(actions); + final offset = snapshot.hasViewOffsetMs ? snapshot.viewOffsetMs : null; + return offset != null && offset > 0 ? offset : null; } Future getPendingSyncCount() async { await _adoptLegacyWatchActionsForActiveProfile(); final profileId = _activeProfileId; return profileId == null || profileId.isEmpty - ? _database.getPendingSyncCount() - : _database.getPendingSyncCount(profileId: profileId); + ? _database.getPendingSyncCount(maxSyncAttempts: maxSyncAttempts) + : _database.getPendingSyncCount(profileId: profileId, maxSyncAttempts: maxSyncAttempts); } /// Sync all pending items to their respective servers. @@ -599,9 +569,9 @@ class OfflineWatchSyncService extends ChangeNotifier { itemId: action.ratingKey, position: position, duration: duration, - offline: true, - updatedAt: DateTime.fromMillisecondsSinceEpoch(action.updatedAt), - continuing: false, + report: PlaybackReportMetadata.offlineReplay( + recordedAt: DateTime.fromMillisecondsSinceEpoch(action.updatedAt), + ), ); } diff --git a/lib/services/playback_context.dart b/lib/services/playback_context.dart new file mode 100644 index 00000000..37fce46e --- /dev/null +++ b/lib/services/playback_context.dart @@ -0,0 +1,31 @@ +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import 'playback_initialization_types.dart'; + +enum PlaybackSourceKind { localFile, remoteDirect, remoteTranscode } + +enum PlaybackReportingMode { online, offlineQueue, onlineWithOfflineFallback, disabled } + +class PlaybackContext { + final MediaItem metadata; + final PlaybackInitializationResult result; + final PlaybackSourceKind sourceKind; + final PlaybackReportingMode reportingMode; + final MediaServerClient? reportingClient; + final String? clientScopeId; + final Map? streamHeaders; + + const PlaybackContext({ + required this.metadata, + required this.result, + required this.sourceKind, + required this.reportingMode, + this.reportingClient, + this.clientScopeId, + this.streamHeaders, + }); + + bool get usesLocalMedia => sourceKind == PlaybackSourceKind.localFile; + bool get shouldQueueOnReportFailure => reportingMode == PlaybackReportingMode.onlineWithOfflineFallback; + bool get shouldQueueOnly => reportingMode == PlaybackReportingMode.offlineQueue; +} diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 43ea1dd4..49afdce5 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -45,7 +45,12 @@ class PlaybackInitializationService { /// /// Returns the local file path if the video is downloaded and completed. /// Returns null if not available offline or database is not provided. - Future getOfflineVideoPath(String serverId, String ratingKey, {int mediaIndex = 0}) async { + Future getOfflineVideoPath( + String serverId, + String ratingKey, { + int mediaIndex = 0, + String? selectedMediaSourceId, + }) async { if (database == null) { return null; } @@ -64,8 +69,22 @@ class PlaybackInitializationService { return null; } - // Skip offline file if a different version was requested - if (downloadedItem.mediaIndex != mediaIndex) { + final downloadedSourceId = downloadedItem.mediaSourceId; + final requestedSourceId = selectedMediaSourceId?.trim(); + if (requestedSourceId != null && + requestedSourceId.isNotEmpty && + downloadedSourceId != null && + downloadedSourceId.isNotEmpty && + downloadedSourceId != requestedSourceId) { + appLogger.d( + '[VersionTrace] Offline video source is $downloadedSourceId, ' + 'but requested source $requestedSourceId — skipping offline', + ); + return null; + } + + // Legacy rows may not have a media source id, so keep index fallback. + if ((downloadedSourceId == null || downloadedSourceId.isEmpty) && downloadedItem.mediaIndex != mediaIndex) { appLogger.d( '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex}, ' 'but requested version $mediaIndex — skipping offline', @@ -121,7 +140,12 @@ class PlaybackInitializationService { String? offlineVideoPath; if (serverId != null && (preferOffline || client == null) && database != null) { - offlineVideoPath = await getOfflineVideoPath(serverId, metadata.id, mediaIndex: selectedMediaIndex); + offlineVideoPath = await getOfflineVideoPath( + serverId, + metadata.id, + mediaIndex: selectedMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + ); } // Downloaded playback must not wait on a live server. Cached media info diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 529a4fed..15bf0382 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -74,6 +74,8 @@ class PlaybackProgressTracker { /// Whether the final stopped progress event was already emitted locally. bool _stopProgressNotified = false; + Future? _stoppedProgressFuture; + Duration? _lastProgressNotifiedPosition; static const Duration _progressNotifyDelta = Duration(seconds: 30); @@ -162,6 +164,20 @@ class PlaybackProgressTracker { await _sendProgress(state, positionOverride: positionOverride); } + Future sendStoppedProgressOnce({Duration? positionOverride}) { + final existing = _stoppedProgressFuture; + if (existing != null) return existing; + final future = sendProgress('stopped', positionOverride: positionOverride); + _stoppedProgressFuture = future; + return future; + } + + void resumeAfterStoppedReport() { + _stoppedProgressFuture = null; + _stopProgressNotified = false; + _reportSession?.resetAfterStop(); + } + Future _sendProgress(String state, {Duration? positionOverride}) async { Duration? attemptedPosition; Duration? attemptedDuration; diff --git a/lib/services/playback_report_session.dart b/lib/services/playback_report_session.dart index 0ada57bd..34ee0644 100644 --- a/lib/services/playback_report_session.dart +++ b/lib/services/playback_report_session.dart @@ -1,6 +1,7 @@ import 'dart:async'; import '../media/media_server_client.dart'; +import '../media/playback_report_metadata.dart'; enum _PlaybackReportState { idle, starting, started, stopping, stopFailed, stopped } @@ -35,12 +36,14 @@ class PlaybackReportSnapshot { final String state; final Duration position; final Duration duration; + final PlaybackReportMetadata report; final PlaybackStreamSelectionResolver resolveStreamSelection; const PlaybackReportSnapshot({ required this.state, required this.position, required this.duration, + this.report = const PlaybackReportMetadata.live(), this.resolveStreamSelection = _noStreamSelection, }); @@ -70,6 +73,18 @@ class PlaybackReportSession { bool get isIdle => _state == _PlaybackReportState.idle; + bool get isStopped => _state == _PlaybackReportState.stopped; + + void resetAfterStop() { + if (_state == _PlaybackReportState.stopped || _state == _PlaybackReportState.stopFailed) { + _state = _PlaybackReportState.idle; + _startSnapshot = null; + _discardPendingProgress(); + _pumpFuture = null; + _stopFuture = null; + } + } + bool get _isStoppingOrTerminal => _state == _PlaybackReportState.stopping || _state == _PlaybackReportState.stopFailed || @@ -248,6 +263,7 @@ class PlaybackReportSession { duration: snapshot.duration, playSessionId: playSessionId, mediaSourceId: selection.mediaSourceId, + report: snapshot.report, ); } } diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart new file mode 100644 index 00000000..387944fd --- /dev/null +++ b/lib/services/playback_source_resolver.dart @@ -0,0 +1,79 @@ +import '../database/app_database.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import '../models/transcode_quality_preset.dart'; +import 'multi_server_manager.dart'; +import 'playback_context.dart'; +import 'playback_initialization_service.dart'; + +class PlaybackSourceResolver { + final MultiServerManager serverManager; + final AppDatabase database; + + const PlaybackSourceResolver({required this.serverManager, required this.database}); + + Future resolve({ + required MediaItem metadata, + required int selectedMediaIndex, + String? selectedMediaSourceId, + required bool offlineLibraryMode, + required TranscodeQualityPreset qualityPreset, + int? selectedAudioStreamId, + String? sessionIdentifier, + String? transcodeSessionId, + }) async { + final reportingClient = _onlineClient(metadata.serverId); + final service = PlaybackInitializationService(client: reportingClient, database: database); + final result = await service.getPlaybackData( + metadata: metadata, + selectedMediaIndex: selectedMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferOffline: offlineLibraryMode || qualityPreset.isOriginal, + qualityPreset: qualityPreset, + selectedAudioStreamId: selectedAudioStreamId, + sessionIdentifier: sessionIdentifier, + transcodeSessionId: transcodeSessionId, + ); + + final sourceKind = result.usesLocalMedia + ? PlaybackSourceKind.localFile + : result.isTranscoding + ? PlaybackSourceKind.remoteTranscode + : PlaybackSourceKind.remoteDirect; + final reportingMode = _reportingMode( + sourceKind: sourceKind, + client: reportingClient, + offlineLibraryMode: offlineLibraryMode, + ); + final scopeId = reportingClient?.cacheServerId; + + return PlaybackContext( + metadata: metadata, + result: result, + sourceKind: sourceKind, + reportingMode: reportingMode, + reportingClient: reportingClient, + clientScopeId: scopeId == metadata.serverId ? null : scopeId, + streamHeaders: result.usesLocalMedia ? null : reportingClient?.streamHeaders, + ); + } + + MediaServerClient? _onlineClient(String? serverId) { + if (serverId == null || !serverManager.isClientOnline(serverId)) return null; + return serverManager.getClient(serverId); + } + + PlaybackReportingMode _reportingMode({ + required PlaybackSourceKind sourceKind, + required MediaServerClient? client, + required bool offlineLibraryMode, + }) { + if (client != null) { + return sourceKind == PlaybackSourceKind.localFile + ? PlaybackReportingMode.onlineWithOfflineFallback + : PlaybackReportingMode.online; + } + if (sourceKind == PlaybackSourceKind.localFile || offlineLibraryMode) return PlaybackReportingMode.offlineQueue; + return PlaybackReportingMode.disabled; + } +} diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ca45f8ee..a313bcb1 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -16,6 +16,7 @@ import '../media/media_kind.dart'; import '../media/media_library.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; +import '../media/playback_report_metadata.dart'; import '../media/server_capabilities.dart'; import '../utils/external_ids.dart'; import 'bif_thumbnail_service.dart'; @@ -1610,9 +1611,7 @@ class PlexClient required int time, required String state, // 'playing', 'paused', 'stopped', 'buffering' int? duration, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { final response = await _http.post( '/:/timeline', @@ -1622,9 +1621,9 @@ class PlexClient 'time': time, 'state': state, 'duration': ?duration, - if (offline) 'offline': 1, - if (updatedAt != null) 'updated': updatedAt.millisecondsSinceEpoch ~/ 1000, - if (continuing != null) 'continuing': continuing ? 1 : 0, + if (report.isOfflineReplay) 'offline': 1, + if (report.recordedAt != null) 'updated': report.recordedAt!.millisecondsSinceEpoch ~/ 1000, + if (report.willContinue != null) 'continuing': report.willContinue! ? 1 : 0, }, ); // Surface non-2xx instead of swallowing — progress is the cornerstone @@ -3928,17 +3927,13 @@ class PlexClient Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) => updateProgress( itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds, - offline: offline, - updatedAt: updatedAt, - continuing: continuing, + report: report, ); // ── Downloads ──────────────────────────────────────────────────── @@ -3972,7 +3967,11 @@ class PlexClient ); } } - return DownloadResolution(videoUrl: playbackData.videoUrl, externalSubtitles: subtitles); + return DownloadResolution( + videoUrl: playbackData.videoUrl, + mediaSourceId: playbackData.mediaInfo?.mediaSourceId, + externalSubtitles: subtitles, + ); } @override diff --git a/lib/services/watch_state_resolver.dart b/lib/services/watch_state_resolver.dart new file mode 100644 index 00000000..f68a5b15 --- /dev/null +++ b/lib/services/watch_state_resolver.dart @@ -0,0 +1,85 @@ +import '../database/app_database.dart'; +import '../media/media_item.dart'; +import '../utils/watch_state_notifier.dart'; + +class WatchStateSnapshot { + final bool? isWatched; + final bool hasViewOffsetMs; + final int? viewOffsetMs; + + const WatchStateSnapshot({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs}); + + bool get isEmpty => isWatched == null && !hasViewOffsetMs; + + MediaItem apply(MediaItem item) { + var updated = item; + if (isWatched != null) { + updated = updated.copyWith(viewCount: isWatched! ? 1 : 0); + } + if (hasViewOffsetMs) { + updated = updated.copyWith(viewOffsetMs: viewOffsetMs); + } + return updated; + } +} + +class WatchStateResolver { + const WatchStateResolver._(); + + static WatchStateSnapshot fromEvent(WatchStateEvent event) { + return switch (event.changeType) { + WatchStateChangeType.watched => const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), + WatchStateChangeType.unwatched => const WatchStateSnapshot( + isWatched: false, + hasViewOffsetMs: true, + viewOffsetMs: 0, + ), + WatchStateChangeType.progressUpdate => + event.isNowWatched == true + ? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) + : WatchStateSnapshot(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset), + WatchStateChangeType.removedFromContinueWatching => const WatchStateSnapshot( + hasViewOffsetMs: true, + viewOffsetMs: 0, + ), + }; + } + + static WatchStateSnapshot fromActions(Iterable actions) { + OfflineWatchProgressItem? latestManual; + OfflineWatchProgressItem? latestProgress; + + for (final action in actions) { + if (action.actionType == 'watched' || action.actionType == 'unwatched') { + if (latestManual == null || action.updatedAt > latestManual.updatedAt) latestManual = action; + } else if (action.actionType == 'progress') { + if (latestProgress == null || action.updatedAt > latestProgress.updatedAt) latestProgress = action; + } + } + + bool? isWatched; + var hasViewOffsetMs = false; + int? viewOffsetMs; + + final progress = latestProgress; + final manual = latestManual; + final progressIsNewest = progress != null && (manual == null || progress.updatedAt >= manual.updatedAt); + + if (progress != null && progress.shouldMarkWatched && progressIsNewest) { + isWatched = true; + hasViewOffsetMs = true; + viewOffsetMs = 0; + } else if (manual != null) { + isWatched = manual.actionType == 'watched'; + hasViewOffsetMs = true; + viewOffsetMs = 0; + } + + if (progress != null && !progress.shouldMarkWatched && progressIsNewest) { + hasViewOffsetMs = true; + viewOffsetMs = progress.viewOffset; + } + + return WatchStateSnapshot(isWatched: isWatched, hasViewOffsetMs: hasViewOffsetMs, viewOffsetMs: viewOffsetMs); + } +} diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index a41fd4d6..89ae31aa 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -11,6 +11,7 @@ import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../screens/video_player_screen.dart'; import '../services/external_player_service.dart'; +import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import 'app_logger.dart'; @@ -62,6 +63,7 @@ Future navigateToVideoPlayer( // Use the manager-routed lookup so Jellyfin items don't trip the // Plex-only client. The player branches on the returned type internally. final manager = context.read().serverManager; + final offlineWatchService = context.read(); final serverId = metadata.serverId ?? ''; final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(serverId)) ? manager.getClient(serverId) @@ -87,7 +89,11 @@ Future navigateToVideoPlayer( if (isOffline) { final globalKey = metadata.globalKey; - final videoPath = await downloadProvider.getVideoFilePath(globalKey); + final videoPath = await downloadProvider.getVideoFilePath( + globalKey, + mediaIndex: mediaIndex, + mediaSourceId: selectedMediaSourceId, + ); if (videoPath != null && context.mounted) { final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; launched = await ExternalPlayerService.launch( @@ -95,6 +101,7 @@ Future navigateToVideoPlayer( videoUrl: videoUrl, metadata: metadata, client: mediaClient, + offlineWatchService: offlineWatchService, mediaIndex: mediaIndex, mediaSourceId: selectedMediaSourceId, ); @@ -104,6 +111,7 @@ Future navigateToVideoPlayer( context: context, metadata: metadata, client: mediaClient, + offlineWatchService: offlineWatchService, mediaIndex: mediaIndex, mediaSourceId: selectedMediaSourceId, ); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 21036450..b3828063 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -14,6 +14,7 @@ import '../media/media_version.dart'; import '../mixins/controller_disposer_mixin.dart'; import '../services/plex_client.dart'; import '../services/media_list_playback_launcher.dart'; +import '../services/offline_watch_sync_service.dart'; import '../services/playlist_items_loader.dart'; import '../services/trackers/tracker_coordinator.dart'; import '../models/transcode_quality_preset.dart'; @@ -1252,19 +1253,31 @@ class MediaContextMenuState extends State { // Check if the item is downloaded and use local file path if available final downloadProvider = Provider.of(context, listen: false); + final offlineWatchService = Provider.of(context, listen: false); + final client = _getMediaClientForItem(); final globalKey = item.globalKey; if (downloadProvider.isDownloaded(globalKey)) { final videoPath = await downloadProvider.getVideoFilePath(globalKey); if (videoPath != null && context.mounted) { final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; - await ExternalPlayerService.launch(context: context, videoUrl: videoUrl); + await ExternalPlayerService.launch( + context: context, + videoUrl: videoUrl, + metadata: item, + client: client, + offlineWatchService: offlineWatchService, + ); return; } } - final client = _getMediaClientForItem(); if (!context.mounted) return; - await ExternalPlayerService.launch(context: context, metadata: item, client: client); + await ExternalPlayerService.launch( + context: context, + metadata: item, + client: client, + offlineWatchService: offlineWatchService, + ); } /// Handle download collection action — opens the same sync/one-time dialog diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart index a834f6a6..e86fb95e 100644 --- a/test/services/live_session_tracker_test.dart +++ b/test/services/live_session_tracker_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/live_session_tracker.dart'; @@ -45,9 +46,7 @@ class _FakeJellyfinClient implements JellyfinClient { Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { calls.add('stopped:$itemId:$playSessionId'); } diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index ed9da17e..764f9ad0 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -10,6 +10,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; @@ -79,8 +80,7 @@ class _RecordingMediaClient implements MediaServerClient { void close() {} final started = <({String itemId, int positionMs, int? durationMs})>[]; - final stopped = - <({String itemId, int positionMs, int? durationMs, bool offline, DateTime? updatedAt, bool? continuing})>[]; + final stopped = <({String itemId, int positionMs, int? durationMs, PlaybackReportMetadata report})>[]; final watched = []; @override @@ -108,17 +108,13 @@ class _RecordingMediaClient implements MediaServerClient { Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { stopped.add(( itemId: itemId, positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds, - offline: offline, - updatedAt: updatedAt, - continuing: continuing, + report: report, )); } @@ -355,8 +351,8 @@ void main() { expect(client.started.single.positionMs, 50000); expect(client.stopped, hasLength(1)); expect(client.stopped.single.positionMs, 50000); - expect(client.stopped.single.offline, isTrue); - expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt); + expect(client.stopped.single.report.isOfflineReplay, isTrue); + expect(client.stopped.single.report.recordedAt?.millisecondsSinceEpoch, queued!.updatedAt); expect(client.watched, isEmpty); expect(await svc.getPendingSyncCount(), 0); }); @@ -380,9 +376,9 @@ void main() { expect(client.stopped, hasLength(1)); expect(client.stopped.single.positionMs, 100000); expect(client.stopped.single.durationMs, 100000); - expect(client.stopped.single.offline, isTrue); - expect(client.stopped.single.continuing, isFalse); - expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt); + expect(client.stopped.single.report.isOfflineReplay, isTrue); + expect(client.stopped.single.report.willContinue, isFalse); + expect(client.stopped.single.report.recordedAt?.millisecondsSinceEpoch, queued!.updatedAt); expect(client.watched, ['42']); expect(await svc.getPendingSyncCount(), 0); }); @@ -482,7 +478,7 @@ void main() { expect(await svc.getLocalWatchStatus('srv:1'), isFalse); }); - test('returns shouldMarkWatched for a "progress" action', () async { + test('returns true only for progress that crossed the watched threshold', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); addTearDown(() async { svc.dispose(); @@ -490,9 +486,9 @@ void main() { await db.close(); }); - // Below threshold → shouldMarkWatched=false → status=false. + // Below threshold is resume-only, not an explicit unwatched override. await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100); - expect(await svc.getLocalWatchStatus('srv:1'), isFalse); + expect(await svc.getLocalWatchStatus('srv:1'), isNull); // Above threshold → shouldMarkWatched=true → status=true. await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100); @@ -771,7 +767,7 @@ void main() { expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue); expect(await svc.getLocalViewOffset('jf-machine:item-1'), isNull); - expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isFalse); + expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isNull); expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000); }); diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 3cc5497f..fbb87d6f 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; @@ -134,9 +135,7 @@ class _FakePlexClient implements PlexClient { required int time, required String state, int? duration, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { if (throwOnNextCall != null) { final err = throwOnNextCall!; @@ -201,9 +200,7 @@ class _FakePlexClient implements PlexClient { Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) { playbackSessionIds.add(playSessionId); playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null)); @@ -974,9 +971,7 @@ class _ScrobblePreciseClient implements PlexClient { required int time, required String state, int? duration, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async {} @override @@ -1011,9 +1006,7 @@ class _ScrobblePreciseClient implements PlexClient { Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async {} @override diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart index 3855552d..108e7e26 100644 --- a/test/services/playback_report_session_test.dart +++ b/test/services/playback_report_session_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/playback_report_session.dart'; class _RecordingClient implements MediaServerClient { @@ -47,9 +48,7 @@ class _RecordingClient implements MediaServerClient { Duration? duration, String? playSessionId, String? mediaSourceId, - bool offline = false, - DateTime? updatedAt, - bool? continuing, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId'); if (failNextStop) {