diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 2097e3e2..18be415e 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -284,7 +284,7 @@ class AppDatabase extends _$AppDatabase { (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) & (filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)), ) - ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]) ..limit(1)) .getSingleOrNull(); } @@ -303,7 +303,7 @@ class AppDatabase extends _$AppDatabase { (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) & (filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)), ) - ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)])) .get(); } @@ -321,7 +321,7 @@ class AppDatabase extends _$AppDatabase { t.globalKey.isIn(globalKeys) & (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)), ) - ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)])) .get(); final result = >{}; @@ -355,7 +355,7 @@ class AppDatabase extends _$AppDatabase { t.globalKey.isIn(globalKeys) & (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)), ) - ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)])) .get(); // Group by globalKey and take the latest (first due to ordering) @@ -385,7 +385,7 @@ class AppDatabase extends _$AppDatabase { String? clientScopeId, required String ratingKey, required int viewOffset, - required int duration, + required int? duration, required bool shouldMarkWatched, }) async { final globalKey = buildGlobalKey(serverId, ratingKey); diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 6b1536bd..62feb8d3 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -840,19 +840,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } final expectedSourceId = mediaSourceId?.trim(); final downloadedSourceId = downloadedItem.mediaSourceId; - if (expectedSourceId != null && + final comparedBySourceId = + expectedSourceId != null && expectedSourceId.isNotEmpty && downloadedSourceId != null && - downloadedSourceId.isNotEmpty && - expectedSourceId != downloadedSourceId) { + downloadedSourceId.isNotEmpty; + if (comparedBySourceId && 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) { + if (!comparedBySourceId && mediaIndex != null && downloadedItem.mediaIndex != mediaIndex) { appLogger.w( 'Downloaded media index mismatch for $globalKey: have ${downloadedItem.mediaIndex}, expected $mediaIndex', ); diff --git a/lib/providers/watch_state_overlay_provider.dart b/lib/providers/watch_state_overlay_provider.dart index 5f153bc2..d872cc9e 100644 --- a/lib/providers/watch_state_overlay_provider.dart +++ b/lib/providers/watch_state_overlay_provider.dart @@ -34,6 +34,13 @@ class WatchStateOverlayPatch { int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); } +class _WatchStateOverlayEntry { + final WatchStateOverlayPatch patch; + final int sequence; + + const _WatchStateOverlayEntry(this.patch, this.sequence); +} + /// Session-local watch-state overlay for immediate UI freshness. /// /// Server fetches remain the source of truth; this only patches stale @@ -44,20 +51,24 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti } StreamSubscription? _subscription; - final Map _patches = {}; + final Map _patches = {}; String? _activeProfileId; Map _activeClientScopesByServer = const {}; + int _sequence = 0; WatchStateOverlayPatch? patchForGlobalKey(String globalKey) { + _WatchStateOverlayEntry? scopedEntry; 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; + scopedEntry = _patches[buildGlobalKey(scoped, parsed.ratingKey)]; } } - return _patches[globalKey]; + final unscopedEntry = _patches[globalKey]; + if (scopedEntry == null) return unscopedEntry?.patch; + if (unscopedEntry == null) return scopedEntry.patch; + return scopedEntry.sequence >= unscopedEntry.sequence ? scopedEntry.patch : unscopedEntry.patch; } WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey); @@ -99,14 +110,15 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti } void _onWatchStateEvent(WatchStateEvent event) { - final patch = WatchStateOverlayPatch.fromSnapshot(WatchStateResolver.fromEvent(event)); + final snapshot = WatchStateResolver.fromEvent(event); + if (snapshot.isEmpty) return; + final patch = WatchStateOverlayPatch.fromSnapshot(snapshot); 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; + _patches[key] = _WatchStateOverlayEntry(patch, ++_sequence); safeNotifyListeners(); } diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 002e8f54..5d41738d 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -135,7 +135,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { Future _swapEpisodeInPip(MediaItem episodeMetadata) async { _isSwappingEpisode = true; final currentPlayer = player!; - final playbackGeneration = _beginPlaybackGeneration(); + final playbackGeneration = _beginPlaybackGeneration(isEpisodeSwap: true); final previousMetadata = _currentMetadata; final currentAudioTrack = currentPlayer.state.track.audio; @@ -161,6 +161,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { unawaited(TraktScrobbleService.instance.stopPlayback()); unawaited(TrackerCoordinator.instance.stopPlayback()); + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; + _currentMetadata = episodeMetadata; VideoPlayerScreenState._activeId = episodeMetadata.id; _showPlayNextDialog = false; @@ -179,6 +181,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { sessionIdentifier: _playbackSessionIdentifier, transcodeSessionId: _playbackTranscodeSessionId, ); + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; final result = playbackContext.result; final mediaClient = playbackContext.reportingClient; final plexClient = mediaClient is PlexClient ? mediaClient : null; @@ -204,6 +207,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (_isOfflinePlayback) { final localOffset = await offlineWatchService.getLocalViewOffset(episodeMetadata.globalKey); + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; if (localOffset != null && localOffset > 0) { resumePosition = Duration(milliseconds: localOffset); } @@ -218,6 +222,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { await currentPlayer.setDisplayCriteria( !result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null, ); + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; final openTiming = _playbackOpenTiming( backend: episodeMetadata.backend, isTranscoding: result.isTranscoding, @@ -225,6 +230,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { durationMs: episodeMetadata.durationMs, ); await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no'); + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; await currentPlayer.open( Media(result.videoUrl!, start: openTiming.mediaStart, headers: result.usesLocalMedia ? null : streamHeaders), play: isExoPlayer || !hasExternalSubs, @@ -233,11 +239,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { timelineDuration: openTiming.timelineDuration, ); + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; _completionTriggered = false; _isSwappingEpisode = false; - if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; - _scrubPreviewSource?.dispose(); _setPlayerState(() { _availableVersions = result.availableVersions; @@ -247,7 +252,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { }); _trackManager?.dispose(); - _trackManager = TrackManager( + final trackManager = TrackManager( player: currentPlayer, isActive: () => mounted && player != null, // Plex writes track changes immediately. Jellyfin persists selected @@ -264,18 +269,21 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (mounted) showAppSnackBar(context, message, duration: duration); }, ); - _trackManager!.cacheExternalSubtitles(result.externalSubtitles); + _trackManager = trackManager; + trackManager.cacheExternalSubtitles(result.externalSubtitles); if (player is! PlayerAndroid && hasExternalSubs) { - _trackManager!.waitingForExternalSubsTrackSelection = true; + trackManager.waitingForExternalSubsTrackSelection = true; try { - await _trackManager!.addExternalSubtitles(result.externalSubtitles); + await trackManager.addExternalSubtitles(result.externalSubtitles); } finally { - await _trackManager!.resumeAfterSubtitleLoad(); + await trackManager.resumeAfterSubtitleLoad(); } + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; } else { - _trackManager!.applyTrackSelectionWhenReady(); + trackManager.applyTrackSelectionWhenReady(); } + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; // Same helper as the initial start flow, so any future change lands in // both paths together. @@ -295,11 +303,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { } await _loadAdjacentEpisodes(); + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; if (_autoPipEnabled) { unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); } } catch (e) { + if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return; _isSwappingEpisode = false; _completionTriggered = false; _currentMetadata = previousMetadata; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 1ab267f3..18aa92b1 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -437,7 +437,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time); - int _beginPlaybackGeneration() => ++_playbackGeneration; + int _beginPlaybackGeneration({bool isEpisodeSwap = false}) { + if (!isEpisodeSwap) _isSwappingEpisode = false; + return ++_playbackGeneration; + } bool _isCurrentPlaybackGeneration(int generation, Player currentPlayer) { return mounted && player == currentPlayer && _playbackGeneration == generation; diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index ba956a90..3787e414 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -13,7 +13,6 @@ 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'); @@ -173,41 +172,73 @@ class ExternalPlayerService { } try { - final session = PlaybackReportSession(client: client, itemId: metadata.id, playMethod: 'DirectPlay'); - await session.report( - PlaybackReportSnapshot( - state: 'playing', - position: position, - duration: duration ?? position, - resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId), - ), + await client.reportPlaybackStarted( + itemId: metadata.id, + position: position, + duration: duration, + playMethod: 'DirectPlay', + mediaSourceId: mediaSourceId, ); - await session.report( - PlaybackReportSnapshot( - state: 'stopped', - position: position, - duration: duration ?? position, - resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId), - ), + } catch (e) { + appLogger.d('External player progress: started call failed (continuing)', error: e); + } + + try { + await client.reportPlaybackStopped( + itemId: metadata.id, + position: position, + duration: duration, + mediaSourceId: mediaSourceId, ); - - if (duration == null) return; - - WatchStateNotifier().notifyProgress( - item: metadata, - viewOffset: position.inMilliseconds, - duration: duration.inMilliseconds, - watchedThreshold: client.watchedThreshold, - ); - - if (position.inMilliseconds / duration.inMilliseconds >= client.watchedThreshold) { - await client.markWatched(metadata); - unawaited(TrackerCoordinator.instance.markWatched(metadata, client)); - } } catch (e) { appLogger.w('Failed to sync external player progress for ${metadata.id}', error: e); await _queueExternalProgress(metadata, offlineWatchService, position: position, duration: duration); + return; } + + if (duration == null) return; + + WatchStateNotifier().notifyProgress( + item: metadata, + viewOffset: position.inMilliseconds, + duration: duration.inMilliseconds, + watchedThreshold: client.watchedThreshold, + ); + + if (position.inMilliseconds / duration.inMilliseconds >= client.watchedThreshold) { + try { + await client.markWatched(metadata); + unawaited(TrackerCoordinator.instance.markWatched(metadata, client)); + } catch (e) { + appLogger.w('Failed to mark external playback watched for ${metadata.id}', error: e); + } + } + } + + @visibleForTesting + static Future reportAndroidExternalProgressForTesting({ + required int? positionMs, + required int? durationMs, + bool playbackCompleted = false, + bool playbackError = false, + required MediaItem metadata, + required MediaServerClient? client, + OfflineWatchSyncService? offlineWatchService, + String? mediaSourceId, + }) { + return _reportAndroidExternalProgress( + _ExternalPlayerLaunchResult( + launched: true, + positionMs: positionMs, + durationMs: durationMs, + playbackCompleted: playbackCompleted, + playbackError: playbackError, + ), + metadata: metadata, + client: client, + offlineWatchService: offlineWatchService, + mediaSourceId: mediaSourceId, + ); } static Future _queueExternalProgress( @@ -217,12 +248,14 @@ class ExternalPlayerService { required Duration? duration, }) async { final serverId = metadata.serverId; - if (offlineWatchService == null || serverId == null || duration == null || duration.inMilliseconds <= 0) return; + if (offlineWatchService == null || serverId == null) return; await offlineWatchService.queueProgressUpdate( serverId: serverId, itemId: metadata.id, - viewOffset: position.inMilliseconds.clamp(0, duration.inMilliseconds).toInt(), - duration: duration.inMilliseconds, + viewOffset: duration == null + ? position.inMilliseconds + : position.inMilliseconds.clamp(0, duration.inMilliseconds).toInt(), + duration: duration?.inMilliseconds, ); } diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index cab0c773..1e7bb01c 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -217,9 +217,9 @@ class OfflineWatchSyncService extends ChangeNotifier { required String serverId, required String itemId, required int viewOffset, - required int duration, + required int? duration, }) async { - final shouldMarkWatched = isWatchedByProgress(viewOffset, duration, serverId: serverId); + final shouldMarkWatched = duration != null && isWatchedByProgress(viewOffset, duration, serverId: serverId); final clientScopeId = await _clientScopeIdForItem(serverId, itemId); await _database.upsertProgressAction( @@ -232,8 +232,12 @@ class OfflineWatchSyncService extends ChangeNotifier { shouldMarkWatched: shouldMarkWatched, ); + final durationLabel = duration == null ? 'unknown' : '${(duration / 1000).toStringAsFixed(0)}s'; + final percentLabel = duration == null || duration <= 0 + ? 'unknown' + : '${((viewOffset / duration) * 100).toStringAsFixed(1)}%'; appLogger.d( - 'Queued offline progress: $serverId:$itemId at ${(viewOffset / 1000).toStringAsFixed(0)}s / ${(duration / 1000).toStringAsFixed(0)}s (${((viewOffset / duration) * 100).toStringAsFixed(1)}%)', + 'Queued offline progress: $serverId:$itemId at ${(viewOffset / 1000).toStringAsFixed(0)}s / $durationLabel ($percentLabel)', ); notifyListeners(); @@ -552,9 +556,11 @@ class OfflineWatchSyncService extends ChangeNotifier { // Push resumable progress, or a completed offline playback. Jellyfin's // `/Sessions/Playing/Stopped` ignores events without an open session // row, so non-Plex backends still get a lightweight Started call. - if (action.viewOffset != null && action.duration != null) { - final duration = Duration(milliseconds: action.duration!); - final position = action.shouldMarkWatched ? duration : Duration(milliseconds: action.viewOffset!); + if (action.viewOffset != null) { + final duration = action.duration == null ? null : Duration(milliseconds: action.duration!); + final position = action.shouldMarkWatched && duration != null + ? duration + : Duration(milliseconds: action.viewOffset!); if (!action.shouldMarkWatched || client.backend != MediaBackend.plex) { try { await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration); diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 49afdce5..45cccb8f 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -71,11 +71,12 @@ class PlaybackInitializationService { final downloadedSourceId = downloadedItem.mediaSourceId; final requestedSourceId = selectedMediaSourceId?.trim(); - if (requestedSourceId != null && + final comparedBySourceId = + requestedSourceId != null && requestedSourceId.isNotEmpty && downloadedSourceId != null && - downloadedSourceId.isNotEmpty && - downloadedSourceId != requestedSourceId) { + downloadedSourceId.isNotEmpty; + if (comparedBySourceId && downloadedSourceId != requestedSourceId) { appLogger.d( '[VersionTrace] Offline video source is $downloadedSourceId, ' 'but requested source $requestedSourceId — skipping offline', @@ -83,8 +84,8 @@ class PlaybackInitializationService { return null; } - // Legacy rows may not have a media source id, so keep index fallback. - if ((downloadedSourceId == null || downloadedSourceId.isEmpty) && downloadedItem.mediaIndex != mediaIndex) { + // Fall back to index when either side lacks a stable source id. + if (!comparedBySourceId && downloadedItem.mediaIndex != mediaIndex) { appLogger.d( '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex}, ' 'but requested version $mediaIndex — skipping offline', diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 15bf0382..6d52f8d8 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -174,7 +174,6 @@ class PlaybackProgressTracker { void resumeAfterStoppedReport() { _stoppedProgressFuture = null; - _stopProgressNotified = false; _reportSession?.resetAfterStop(); } diff --git a/lib/services/playback_report_session.dart b/lib/services/playback_report_session.dart index 34ee0644..7dd81d23 100644 --- a/lib/services/playback_report_session.dart +++ b/lib/services/playback_report_session.dart @@ -70,15 +70,21 @@ class PlaybackReportSession { _PendingProgressReport? _pendingProgress; Future? _pumpFuture; Future? _stopFuture; + bool _resetAfterStopRequested = false; bool get isIdle => _state == _PlaybackReportState.idle; bool get isStopped => _state == _PlaybackReportState.stopped; void resetAfterStop() { + if (_state == _PlaybackReportState.stopping) { + _resetAfterStopRequested = true; + return; + } if (_state == _PlaybackReportState.stopped || _state == _PlaybackReportState.stopFailed) { _state = _PlaybackReportState.idle; _startSnapshot = null; + _resetAfterStopRequested = false; _discardPendingProgress(); _pumpFuture = null; _stopFuture = null; @@ -203,10 +209,16 @@ class PlaybackReportSession { await _sendStopped(snapshot); stopSucceeded = true; } finally { + final shouldReset = _resetAfterStopRequested; + _resetAfterStopRequested = false; _stopFuture = null; _discardPendingProgress(); _pumpFuture = null; - _state = stopSucceeded ? _PlaybackReportState.stopped : _PlaybackReportState.stopFailed; + _state = shouldReset + ? _PlaybackReportState.idle + : stopSucceeded + ? _PlaybackReportState.stopped + : _PlaybackReportState.stopFailed; } } diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index 387944fd..e31ebaef 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -22,7 +22,7 @@ class PlaybackSourceResolver { String? sessionIdentifier, String? transcodeSessionId, }) async { - final reportingClient = _onlineClient(metadata.serverId); + final reportingClient = _playbackClient(metadata.serverId, offlineLibraryMode: offlineLibraryMode); final service = PlaybackInitializationService(client: reportingClient, database: database); final result = await service.getPlaybackData( metadata: metadata, @@ -58,9 +58,11 @@ class PlaybackSourceResolver { ); } - MediaServerClient? _onlineClient(String? serverId) { - if (serverId == null || !serverManager.isClientOnline(serverId)) return null; - return serverManager.getClient(serverId); + MediaServerClient? _playbackClient(String? serverId, {required bool offlineLibraryMode}) { + if (serverId == null) return null; + final client = serverManager.getClient(serverId); + if (offlineLibraryMode && !serverManager.isClientOnline(serverId)) return null; + return client; } PlaybackReportingMode _reportingMode({ diff --git a/lib/services/watch_state_resolver.dart b/lib/services/watch_state_resolver.dart index f68a5b15..1bfbde88 100644 --- a/lib/services/watch_state_resolver.dart +++ b/lib/services/watch_state_resolver.dart @@ -37,49 +37,37 @@ class WatchStateResolver { 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, - ), + : WatchStateSnapshot( + isWatched: false, + hasViewOffsetMs: event.viewOffset != null, + viewOffsetMs: event.viewOffset, + ), + WatchStateChangeType.removedFromContinueWatching => const WatchStateSnapshot(), }; } static WatchStateSnapshot fromActions(Iterable actions) { - OfflineWatchProgressItem? latestManual; - OfflineWatchProgressItem? latestProgress; + OfflineWatchProgressItem? latest; 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; + if (action.actionType != 'watched' && action.actionType != 'unwatched' && action.actionType != 'progress') { + continue; } + if (latest == null || action.updatedAt > latest.updatedAt) latest = 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); + return switch (latest?.actionType) { + 'watched' => const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), + 'unwatched' => const WatchStateSnapshot(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), + 'progress' => + latest!.shouldMarkWatched + ? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) + : WatchStateSnapshot( + isWatched: false, + hasViewOffsetMs: latest.viewOffset != null, + viewOffsetMs: latest.viewOffset, + ), + _ => const WatchStateSnapshot(), + }; } } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index ec62526b..c192ee79 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -125,6 +125,31 @@ void main() { }); }); + group('DownloadProvider — local file selection', () { + test('falls back to media index when caller has no source id', () async { + const globalKey = 'srv:movie-1'; + await db.insertDownload( + serverId: 'srv', + ratingKey: 'movie-1', + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + mediaIndex: 0, + mediaSourceId: 'source-a', + ); + await db.updateVideoFilePath(globalKey, 'content://offline/movie-1-v1'); + + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState(ownedDownloadKeys: {globalKey}); + + expect(await p.getVideoFilePath(globalKey, mediaIndex: 1), isNull); + expect(await p.getVideoFilePath(globalKey, mediaIndex: 0), 'content://offline/movie-1-v1'); + + p.dispose(); + }); + }); + group('DownloadProvider — sync rule CRUD', () { test('createSyncRule inserts into the database and updates the in-memory map', () async { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); diff --git a/test/providers/watch_state_overlay_provider_test.dart b/test/providers/watch_state_overlay_provider_test.dart index 02eb818c..e305ef89 100644 --- a/test/providers/watch_state_overlay_provider_test.dart +++ b/test/providers/watch_state_overlay_provider_test.dart @@ -1,65 +1,68 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/media/media_backend.dart'; -import 'package:plezy/media/media_item.dart'; -import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/watch_state_overlay_provider.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; -MediaItem _item({String id = '1', int? viewOffsetMs, int? viewCount = 0}) { - return MediaItem( - id: id, - backend: MediaBackend.plex, - kind: MediaKind.movie, - title: 'Movie', - serverId: 'server', - durationMs: 100000, - viewOffsetMs: viewOffsetMs, - viewCount: viewCount, +Future _emit(WatchStateEvent event) async { + WatchStateNotifier().notify(event); + await Future.delayed(Duration.zero); +} + +WatchStateEvent _event({ + required WatchStateChangeType changeType, + required bool? isNowWatched, + String serverId = 'jf-machine', + String itemId = 'item-1', + String? cacheServerId, + int? viewOffset, +}) { + return WatchStateEvent( + itemId: itemId, + serverId: serverId, + cacheServerId: cacheServerId, + changeType: changeType, + parentChain: const [], + mediaType: 'movie', + isNowWatched: isNowWatched, + viewOffset: viewOffset, ); } -Future _drainEvents() => Future.delayed(Duration.zero); - void main() { - group('WatchStateOverlayProvider', () { - test('applies watched patches immediately', () async { - final provider = WatchStateOverlayProvider(); - addTearDown(provider.dispose); - final item = _item(viewOffsetMs: 40000); + test('removed from continue watching does not replace an existing watched patch', () async { + final provider = WatchStateOverlayProvider(); + addTearDown(provider.dispose); - WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); - await _drainEvents(); + await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true)); + await _emit(_event(changeType: WatchStateChangeType.removedFromContinueWatching, isNowWatched: null)); - final patched = provider.apply(item); - expect(patched.isWatched, isTrue); - expect(patched.viewOffsetMs, 0); - }); + final patch = provider.patchForGlobalKey('jf-machine:item-1'); + expect(patch?.isWatched, isTrue); + expect(patch?.viewOffsetMs, 0); + }); - test('applies progress patches without changing watched state', () async { - final provider = WatchStateOverlayProvider(); - addTearDown(provider.dispose); - final item = _item(viewCount: 1); + test('newer unscoped patch wins over older active scoped patch', () async { + final provider = WatchStateOverlayProvider(); + addTearDown(provider.dispose); + provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); - WatchStateNotifier().notifyProgress(item: item, viewOffset: 30000, duration: 100000); - await _drainEvents(); + await _emit( + _event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'), + ); + await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false)); - final patched = provider.apply(item); - expect(patched.isWatched, isTrue); - expect(patched.viewOffsetMs, 30000); - }); + expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isFalse); + }); - test('clears patches when active profile changes', () async { - final provider = WatchStateOverlayProvider(); - addTearDown(provider.dispose); - final item = _item(); + test('newer active scoped patch wins over older unscoped patch', () async { + final provider = WatchStateOverlayProvider(); + addTearDown(provider.dispose); + provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); - provider.setActiveProfileId('a'); - WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); - await _drainEvents(); - expect(provider.apply(item).isWatched, isTrue); + await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false)); + await _emit( + _event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'), + ); - provider.setActiveProfileId('b'); - expect(provider.apply(item).isWatched, isFalse); - }); + expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue); }); } diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart new file mode 100644 index 00000000..bf2bc8a7 --- /dev/null +++ b/test/services/external_player_service_test.dart @@ -0,0 +1,111 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +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/external_player_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/offline_watch_sync_service.dart'; + +class _RecordingClient implements MediaServerClient { + bool failStart = false; + bool failStop = false; + final started = <({int positionMs, int? durationMs})>[]; + final stopped = <({int positionMs, int? durationMs})>[]; + + @override + String get serverId => 'srv'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + double get watchedThreshold => 0.9; + + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + started.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); + if (failStart) throw StateError('start failed'); + } + + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), + }) async { + stopped.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); + if (failStop) throw StateError('stop failed'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +MediaItem _item({int? durationMs}) { + return MediaItem( + id: 'item-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: 'srv', + durationMs: durationMs, + ); +} + +void main() { + test('Android external progress preserves null duration and still stops after start failure', () async { + final client = _RecordingClient()..failStart = true; + + await ExternalPlayerService.reportAndroidExternalProgressForTesting( + positionMs: 5000, + durationMs: null, + metadata: _item(), + client: client, + ); + + expect(client.started, [(positionMs: 5000, durationMs: null)]); + expect(client.stopped, [(positionMs: 5000, durationMs: null)]); + }); + + test('Android external progress queues unknown-duration resume when no client is available', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + final manager = MultiServerManager(); + final service = OfflineWatchSyncService(database: db, serverManager: manager); + addTearDown(() async { + service.dispose(); + manager.dispose(); + await db.close(); + }); + + await ExternalPlayerService.reportAndroidExternalProgressForTesting( + positionMs: 5000, + durationMs: null, + metadata: _item(), + client: null, + offlineWatchService: service, + ); + + final action = await db.getLatestWatchAction('srv:item-1'); + expect(action, isNotNull); + expect(action!.viewOffset, 5000); + expect(action.duration, isNull); + expect(action.shouldMarkWatched, isFalse); + }); +} diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 764f9ad0..f7ea4d6d 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -357,6 +357,30 @@ void main() { expect(await svc.getPendingSyncCount(), 0); }); + test('unknown-duration offline progress still replays stopped position', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex); + mgr.debugRegisterClientForTesting(client); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50000, duration: null); + + await svc.syncPendingItems(); + + expect(client.started, hasLength(1)); + expect(client.started.single.positionMs, 50000); + expect(client.started.single.durationMs, isNull); + expect(client.stopped, hasLength(1)); + expect(client.stopped.single.positionMs, 50000); + expect(client.stopped.single.durationMs, isNull); + expect(client.watched, isEmpty); + expect(await svc.getPendingSyncCount(), 0); + }); + test('completed Plex offline progress replays at duration and marks watched', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); addTearDown(() async { @@ -409,6 +433,24 @@ void main() { expect(action.shouldMarkWatched, isFalse); }); + test('persists unknown-duration progress without marking watched', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50, duration: null); + + final action = await db.getLatestWatchAction('srv:42'); + expect(action, isNotNull); + expect(action!.actionType, 'progress'); + expect(action.viewOffset, 50); + expect(action.duration, isNull); + expect(action.shouldMarkWatched, isFalse); + }); + test('persists shouldMarkWatched=true at/above the default 0.9 threshold', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); addTearDown(() async { @@ -486,9 +528,9 @@ void main() { await db.close(); }); - // Below threshold is resume-only, not an explicit unwatched override. + // Below threshold is explicit local progress, so it overrides stale watched metadata. await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100); - expect(await svc.getLocalWatchStatus('srv:1'), isNull); + expect(await svc.getLocalWatchStatus('srv:1'), isFalse); // Above threshold → shouldMarkWatched=true → status=true. await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100); @@ -767,7 +809,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'), isNull); + expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isFalse); expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000); }); diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 4cc762fe..af2f95dd 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -134,6 +134,22 @@ void main() { expect(result.mediaInfo?.audioTracks.single.languageCode, 'fre'); }); + test('offline path falls back to media index when caller has no source id', () async { + await _insertDownloaded( + db, + serverId: 'srv-1', + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1-v1', + mediaIndex: 0, + mediaSourceId: 'source-a', + ); + + final service = PlaybackInitializationService(database: db); + + expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 1), null); + expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 0), 'content://offline/movie-1-v1'); + }); + test('pure-offline Jellyfin cache works without a connection row', () async { await _insertDownloaded( db, @@ -312,6 +328,7 @@ Future _insertDownloaded( required String ratingKey, required String videoFilePath, int mediaIndex = 0, + String? mediaSourceId, }) async { await db .into(db.downloadedMedia) @@ -325,6 +342,7 @@ Future _insertDownloaded( status: DownloadStatus.completed.index, videoFilePath: Value(videoFilePath), mediaIndex: Value(mediaIndex), + mediaSourceId: Value(mediaSourceId), ), ); } diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart index 108e7e26..58fce33a 100644 --- a/test/services/playback_report_session_test.dart +++ b/test/services/playback_report_session_test.dart @@ -8,6 +8,7 @@ import 'package:plezy/services/playback_report_session.dart'; class _RecordingClient implements MediaServerClient { final calls = []; Completer? startGate; + Completer? stopGate; bool failNextStop = false; @override @@ -51,6 +52,8 @@ class _RecordingClient implements MediaServerClient { PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId'); + final gate = stopGate; + if (gate != null) await gate.future; if (failNextStop) { failNextStop = false; throw StateError('stop failed'); @@ -174,4 +177,24 @@ void main() { expect(client.calls, ['stopped-attempt:1000:null', 'stopped-attempt:3000:null', 'stopped:3000:null']); }); + + test('resetAfterStop during in-flight stop reopens reporting after stop completes', () async { + final client = _RecordingClient()..stopGate = Completer(); + final session = PlaybackReportSession(client: client, itemId: 'item-1'); + + await session.report(_snapshot('playing', positionMs: 1000)); + client.calls.clear(); + + final stopFuture = session.report(_snapshot('stopped', positionMs: 3000)); + await Future.delayed(Duration.zero); + expect(client.calls, ['stopped-attempt:3000:null']); + + session.resetAfterStop(); + client.stopGate!.complete(); + await stopFuture; + + expect(session.isIdle, isTrue); + expect(await session.report(_snapshot('playing', positionMs: 4000)), isTrue); + expect(client.calls, ['stopped-attempt:3000:null', 'stopped:3000:null', 'started:4000:null:null:null']); + }); } diff --git a/test/services/playback_source_resolver_test.dart b/test/services/playback_source_resolver_test.dart new file mode 100644 index 00000000..8af0024b --- /dev/null +++ b/test/services/playback_source_resolver_test.dart @@ -0,0 +1,62 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +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/models/transcode_quality_preset.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/playback_context.dart'; +import 'package:plezy/services/playback_initialization_types.dart'; +import 'package:plezy/services/playback_source_resolver.dart'; + +class _PlaybackClient implements MediaServerClient { + @override + String get serverId => 'srv'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + double get watchedThreshold => 0.9; + + @override + Map get streamHeaders => const {'X-Test': 'token'}; + + @override + void close() {} + + @override + Future getPlaybackInitialization(PlaybackInitializationOptions options) async { + return PlaybackInitializationResult(availableVersions: const [], videoUrl: 'https://example.com/video.mp4'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('online playback uses registered client even when status is stale offline', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final client = _PlaybackClient(); + manager.debugRegisterClientForTesting(client, online: false); + + final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + offlineLibraryMode: false, + qualityPreset: TranscodeQualityPreset.original, + ); + + expect(context.result.videoUrl, 'https://example.com/video.mp4'); + expect(context.reportingClient, same(client)); + expect(context.reportingMode, PlaybackReportingMode.online); + }); +} diff --git a/test/services/watch_state_resolver_test.dart b/test/services/watch_state_resolver_test.dart new file mode 100644 index 00000000..23abacec --- /dev/null +++ b/test/services/watch_state_resolver_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/services/watch_state_resolver.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; + +OfflineWatchProgressItem _action({ + required String actionType, + required int updatedAt, + int? viewOffset, + int? duration, + bool shouldMarkWatched = false, +}) { + return OfflineWatchProgressItem( + id: updatedAt, + serverId: 'srv', + ratingKey: 'item-1', + globalKey: 'srv:item-1', + actionType: actionType, + viewOffset: viewOffset, + duration: duration, + shouldMarkWatched: shouldMarkWatched, + createdAt: updatedAt, + updatedAt: updatedAt, + syncAttempts: 0, + ); +} + +void main() { + test('newer sub-threshold progress overrides older watched state without watched-plus-resume', () { + final snapshot = WatchStateResolver.fromActions([ + _action(actionType: 'watched', updatedAt: 1), + _action(actionType: 'progress', updatedAt: 2, viewOffset: 5000, duration: 100000), + ]); + + expect(snapshot.isWatched, isFalse); + expect(snapshot.hasViewOffsetMs, isTrue); + expect(snapshot.viewOffsetMs, 5000); + }); + + test('newer watched action clears older progress offset', () { + final snapshot = WatchStateResolver.fromActions([ + _action(actionType: 'progress', updatedAt: 1, viewOffset: 5000, duration: 100000), + _action(actionType: 'watched', updatedAt: 2), + ]); + + expect(snapshot.isWatched, isTrue); + expect(snapshot.hasViewOffsetMs, isTrue); + expect(snapshot.viewOffsetMs, 0); + }); + + test('sub-threshold progress events explicitly clear watched state', () { + final snapshot = WatchStateResolver.fromEvent( + WatchStateEvent( + itemId: 'item-1', + serverId: 'srv', + changeType: WatchStateChangeType.progressUpdate, + parentChain: const [], + mediaType: 'movie', + viewOffset: 5000, + isNowWatched: false, + ), + ); + + expect(snapshot.isWatched, isFalse); + expect(snapshot.viewOffsetMs, 5000); + }); + + test('removed from continue watching is not a watch-state overlay patch', () { + final snapshot = WatchStateResolver.fromEvent( + WatchStateEvent( + itemId: 'item-1', + serverId: 'srv', + changeType: WatchStateChangeType.removedFromContinueWatching, + parentChain: const [], + mediaType: 'movie', + ), + ); + + expect(snapshot.isEmpty, isTrue); + }); +}