From 7501f461b90ce2a52f6d882cbf143535eee9eff4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 29 May 2026 18:49:49 +0200 Subject: [PATCH] fix(playback): sync downloaded watch progress close #1171, close #1183 --- lib/media/media_server_client.dart | 7 +- lib/providers/download_provider.dart | 17 +-- lib/providers/offline_watch_provider.dart | 3 + .../watch_state_overlay_provider.dart | 8 +- .../parts/episode_navigation.dart | 14 +- .../video_player/parts/episode_queue.dart | 6 +- .../video_player/parts/playback_prompts.dart | 8 +- .../video_player/parts/playback_services.dart | 35 ++--- .../video_player/parts/playback_start.dart | 13 +- lib/screens/video_player_screen.dart | 19 ++- .../jellyfin_client/parts/playback.dart | 3 + lib/services/offline_watch_sync_service.dart | 35 +++-- .../playback_initialization_service.dart | 1 + .../playback_initialization_types.dart | 4 + lib/services/playback_progress_tracker.dart | 41 +++++- lib/services/plex_client.dart | 19 ++- lib/utils/video_player_navigation.dart | 14 +- test/providers/download_provider_test.dart | 26 ++++ .../offline_watch_provider_test.dart | 11 ++ test/services/live_session_tracker_test.dart | 3 + .../offline_watch_sync_service_test.dart | 126 +++++++++++++++++- .../playback_progress_tracker_test.dart | 71 +++++++++- .../playback_report_session_test.dart | 3 + 23 files changed, 418 insertions(+), 69 deletions(-) diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 0ef8e446..acaa8d0a 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -493,13 +493,18 @@ abstract class MediaServerClient { }); /// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes - /// the session row. + /// the session row. [offline] and [updatedAt] are used by Plex when replaying + /// queued offline watch progress; backends that have no equivalent may ignore + /// them. Future reportPlaybackStopped({ required String itemId, required Duration position, Duration? duration, String? playSessionId, String? mediaSourceId, + bool offline = false, + DateTime? updatedAt, + bool? continuing, }); /// Resolve the video URL, media info, and external subtitle list for diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 9121906a..f1b2a614 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -373,19 +373,20 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin 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: isWatched ? base.viewOffsetMs : 0, - ); + _metadata[entry.key] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs); } } catch (e) { appLogger.w('Failed to apply offline watch overlay', error: e); @@ -494,9 +495,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } void _onWatchStateChanged(WatchStateEvent event) { - // Progress ticks fire continuously during playback; only react to discrete - // watched/unwatched flips so we don't churn listeners on every frame. - if (event.changeType == WatchStateChangeType.progressUpdate) return; + // Progress ticks fire continuously during playback; only react when a + // progress update crosses the watched threshold. + if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) return; if (event.isNowWatched == null) return; final globalKey = buildGlobalKey(event.serverId, event.itemId); @@ -504,7 +505,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (base == null) return; final isWatched = event.isNowWatched!; - _metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: isWatched ? base.viewOffsetMs : 0); + _metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: 0); // Persist into the per-backend pinned cache so the patch survives reloads // (`_loadPersistedDownloads` rehydrates `_metadata` from the cache). unawaited( diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index 15533b8a..631c90a7 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -76,6 +76,9 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM return localOffset; } + final localStatus = await _syncService.getLocalWatchStatus(globalKey); + if (localStatus == true) return null; + // Fall back to cached metadata final metadata = _downloadProvider.getMetadata(globalKey); return metadata?.viewOffsetMs; diff --git a/lib/providers/watch_state_overlay_provider.dart b/lib/providers/watch_state_overlay_provider.dart index dc94315d..f8485e17 100644 --- a/lib/providers/watch_state_overlay_provider.dart +++ b/lib/providers/watch_state_overlay_provider.dart @@ -81,10 +81,10 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti hasViewOffsetMs: true, viewOffsetMs: 0, ), - WatchStateChangeType.progressUpdate => WatchStateOverlayPatch( - hasViewOffsetMs: event.viewOffset != null, - viewOffsetMs: event.viewOffset, - ), + WatchStateChangeType.progressUpdate => + event.isNowWatched == true + ? const WatchStateOverlayPatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0) + : WatchStateOverlayPatch(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset), WatchStateChangeType.removedFromContinueWatching => null, }; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 1c15b141..d4f29cae 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -81,7 +81,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { context, metadata: episodeMetadata, usePushReplacement: true, - isOffline: _isOfflinePlayback, + isOffline: widget.isOffline, ), ); } @@ -97,7 +97,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { context, metadata: episodeMetadata, usePushReplacement: true, - isOffline: _isOfflinePlayback, + isOffline: widget.isOffline, ), ); } @@ -123,7 +123,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { preferredSubtitleTrack: currentSubtitleTrack, preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, usePushReplacement: true, - isOffline: _isOfflinePlayback, + isOffline: widget.isOffline, ), ); } @@ -146,9 +146,9 @@ 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 = _isOfflinePlayback ? null : _getMediaServerClient(context); + final mediaClient = _getOnlineMediaServerClient(context); final plexClient = mediaClient is PlexClient ? mediaClient : null; - final streamHeaders = mediaClient?.streamHeaders ?? const {}; + final streamHeaders = mediaClient?.streamHeaders; final offlineWatchService = context.read(); final userProfileProvider = context.read(); final playbackState = context.read(); @@ -176,7 +176,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final result = await playbackService.getPlaybackData( metadata: episodeMetadata, selectedMediaIndex: widget.selectedMediaIndex, - preferOffline: _isOfflinePlayback || _selectedQualityPreset.isOriginal, + preferOffline: widget.isOffline || _selectedQualityPreset.isOriginal, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, sessionIdentifier: _playbackSessionIdentifier, @@ -224,7 +224,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { ); await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no'); await currentPlayer.open( - Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders), + Media(result.videoUrl!, start: openTiming.mediaStart, headers: result.usesLocalMedia ? null : streamHeaders), play: isExoPlayer || !hasExternalSubs, externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, timelineOffset: openTiming.timelineOffset, diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index baf197e3..9517e3d3 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -5,8 +5,8 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { Future _ensurePlayQueue() async { if (!mounted) return; - // Skip play queue in offline mode (requires server connection) - if (_isOfflinePlayback) return; + // Download/offline library mode uses the local downloaded queue instead. + if (widget.isOffline) return; // Skip play queue for live TV (would interfere with tuner session) if (widget.isLive) return; @@ -80,7 +80,7 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { Future _loadAdjacentEpisodes() async { if (!mounted || widget.isLive) return; - if (_isOfflinePlayback) { + if (widget.isOffline) { // Offline mode: find next/previous from downloaded episodes _loadAdjacentEpisodesOffline(); return; diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 784040d6..0d5ee4d7 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -12,7 +12,12 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { // mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged // never fires false. Normalize all playback-dependent state. unawaited(_setWakelock(false)); - unawaited(_progressTracker?.sendProgress('paused')); + final duration = player?.state.duration; + unawaited( + duration != null && duration.inMilliseconds > 0 + ? _sendStoppedProgressOnce(positionOverride: duration) + : _sendStoppedProgressOnce(), + ); _updateMediaControlsPlaybackState(); unawaited(DiscordRPCService.instance.pausePlayback()); unawaited(TraktScrobbleService.instance.pausePlayback()); @@ -83,6 +88,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _cancelAutoPlay() { _autoPlayTimer?.cancel(); + _stoppedProgressFuture = null; _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 a4ab8be8..0c4633ea 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -22,9 +22,21 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (currentPlayer == null) return; _stoppedProgressFuture = null; - // Progress tracker — offline mode queues for later sync; online mode - // dispatches to the right backend through the neutral client. - if (_isOfflinePlayback) { + // Progress tracker — local media still reports live when its server is + // online; only queue locally when no reporting client is reachable. + if (mediaClient != null) { + _progressTracker = PlaybackProgressTracker( + client: mediaClient, + metadata: metadata, + player: currentPlayer, + offlineWatchService: offlineWatchService, + queueOnOnlineFailure: _usesLocalPlaybackSource, + playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'), + playSessionId: playSessionId, + mediaInfo: mediaInfo, + ); + _progressTracker!.startTracking(); + } else if (_isOfflinePlayback) { _progressTracker = PlaybackProgressTracker( client: null, metadata: metadata, @@ -33,16 +45,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { offlineWatchService: offlineWatchService, ); _progressTracker!.startTracking(); - } else if (mediaClient != null) { - _progressTracker = PlaybackProgressTracker( - client: mediaClient, - metadata: metadata, - player: currentPlayer, - playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'), - playSessionId: playSessionId, - mediaInfo: mediaInfo, - ); - _progressTracker!.startTracking(); } // Media controls metadata. Fire-and-forget — the OS plugin downloads @@ -78,10 +80,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { return; } - // Get client (null in offline mode). Backend-neutral lookup so Jellyfin - // items also wire a [PlaybackProgressTracker]; the tracker dispatches - // to the right backend's reporting endpoints internally. - final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context); + // Get a live reporting client when possible. Downloaded/local playback + // still uses this path when the server is reachable. + final mediaClient = _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 376ac0e7..bb9d8e4a 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -135,7 +135,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // (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 = _getMediaServerClient(context); + final cachedSourceClient = _getOnlineMediaServerClient(context); final offlineService = PlaybackInitializationService( client: cachedSourceClient, database: context.read(), @@ -149,6 +149,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { if (result.videoUrl == null) { throw PlaybackException(t.messages.fileInfoNotAvailable); } + if (!result.usesLocalMedia) { + streamHeaders = cachedSourceClient?.streamHeaders; + } + _isTranscoding = result.isTranscoding; + _effectiveIsOffline = result.isOffline; + _playbackPlaySessionId = result.playSessionId; + _playbackPlayMethod = result.playMethod; + _selectedAudioStreamId = result.activeAudioStreamId; } else { // Online path: `_playbackDataFuture` was kicked off in `_initializePlayer` // in parallel with MPV setup. Quality preset + server capabilities + @@ -160,6 +168,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { } result = await playbackDataFuture; if (!mounted || player != currentPlayer) return; + if (result.usesLocalMedia) { + streamHeaders = null; + } _isTranscoding = result.isTranscoding; _effectiveIsOffline = result.isOffline; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 97c87dc6..fb266be4 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -420,6 +420,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin return context.read().serverManager.getClient(id); } + MediaServerClient? _getOnlineMediaServerClient(BuildContext context) { + final id = _currentMetadata.serverId; + if (id == null) return null; + final manager = context.read().serverManager; + if (!manager.isClientOnline(id)) return null; + return manager.getClient(id); + } + + bool get _usesLocalPlaybackSource => _effectiveIsOffline; + bool get _isOfflinePlayback => widget.isOffline || _effectiveIsOffline; ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time); @@ -442,7 +452,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _playbackSessionIdentifier = widget.reusedSessionIdentifier ?? generateSessionIdentifier(); _playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? generateSessionIdentifier(); _selectedAudioStreamId = widget.selectedAudioStreamId; - _effectiveIsOffline = widget.isOffline; + _effectiveIsOffline = false; _selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original; _liveChannelIndex = widget.liveCurrentChannelIndex ?? -1; @@ -1258,14 +1268,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin String get playbackSessionIdentifier => _playbackSessionIdentifier; String get playbackTranscodeSessionId => _playbackTranscodeSessionId; - Future _sendStoppedProgressOnce() { + 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').catchError((Object e, StackTrace st) { + final future = tracker.sendProgress('stopped', positionOverride: positionOverride).catchError(( + Object e, + StackTrace st, + ) { appLogger.d('Stopped progress flush failed', error: e, stackTrace: st); }); _stoppedProgressFuture = future; diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 37ae88e0..ce497c5e 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -647,6 +647,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { Duration? duration, String? playSessionId, String? mediaSourceId, + bool offline = false, + DateTime? updatedAt, + bool? continuing, }) 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 48a6daff..720940d4 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -363,6 +363,7 @@ class OfflineWatchSyncService extends ChangeNotifier { // Only return offset for progress actions if (action.actionType == OfflineActionType.progress.id) { + if (action.shouldMarkWatched) return null; return action.viewOffset; } @@ -578,22 +579,30 @@ class OfflineWatchSyncService extends ChangeNotifier { break; case 'progress': - // Push the resume position. Jellyfin's `/Sessions/Playing/Stopped` - // ignores events that arrive without an open session row, so we - // bracket with a Started call. Plex's `/:/timeline` collapses both - // into a single row and treats the second as the canonical state. + // 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 position = Duration(milliseconds: action.viewOffset!); final duration = Duration(milliseconds: action.duration!); - try { - await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration); - } catch (e) { - // Plex sometimes 5xxs the start when nothing follows; treat as - // best-effort and continue to the stop call which is the one - // that actually persists the resume position. - appLogger.d('Offline progress: started call failed (continuing)', error: e); + final position = action.shouldMarkWatched ? duration : Duration(milliseconds: action.viewOffset!); + if (!action.shouldMarkWatched || client.backend != MediaBackend.plex) { + try { + await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration); + } catch (e) { + // Plex sometimes 5xxs the start when nothing follows; treat as + // best-effort and continue to the stop call which is the one + // that actually persists the resume position. + appLogger.d('Offline progress: started call failed (continuing)', error: e); + } } - await client.reportPlaybackStopped(itemId: action.ratingKey, position: position, duration: duration); + await client.reportPlaybackStopped( + itemId: action.ratingKey, + position: position, + duration: duration, + offline: true, + updatedAt: DateTime.fromMillisecondsSinceEpoch(action.updatedAt), + continuing: false, + ); } // If progress exceeded threshold, also mark as watched. diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index fdb92daa..43ea1dd4 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -191,6 +191,7 @@ class PlaybackInitializationService { mediaInfo: mediaInfo, externalSubtitles: sidecarSubtitles, isOffline: true, + playMethod: 'DirectPlay', ); } diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index c03b62c5..d84a33d7 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -77,6 +77,10 @@ class PlaybackInitializationResult { /// expects one of `DirectPlay`, `DirectStream`, or `Transcode`. final String? playMethod; + /// True when [videoUrl] points at a downloaded/local copy. This is a media + /// source detail, not a statement about whether server reporting is possible. + bool get usesLocalMedia => isOffline; + PlaybackInitializationResult({ required this.availableVersions, this.videoUrl, diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index c2388f8c..529a4fed 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -37,6 +37,10 @@ class PlaybackProgressTracker { /// Service for queuing offline progress updates final OfflineWatchSyncService? offlineWatchService; + /// Queue the latest progress locally if online reporting fails. Used for + /// downloaded/local playback where playback can continue without a server. + final bool queueOnOnlineFailure; + final String? playMethod; /// Backend session ID to echo in progress reports. Jellyfin uses this to @@ -82,6 +86,7 @@ class PlaybackProgressTracker { required this.player, this.isOffline = false, this.offlineWatchService, + this.queueOnOnlineFailure = false, this.playMethod, this.playSessionId, this.mediaInfo, @@ -152,15 +157,19 @@ class PlaybackProgressTracker { appLogger.d('Stopped progress tracking'); } - /// [state] can be 'playing', 'paused', or 'stopped' - Future sendProgress(String state) async { - await _sendProgress(state); + /// [state] can be 'playing', 'paused', or 'stopped'. + Future sendProgress(String state, {Duration? positionOverride}) async { + await _sendProgress(state, positionOverride: positionOverride); } - Future _sendProgress(String state) async { + Future _sendProgress(String state, {Duration? positionOverride}) async { + Duration? attemptedPosition; + Duration? attemptedDuration; try { - final position = player.state.position; final duration = player.state.duration; + final position = _clampPosition(positionOverride ?? player.state.position, duration); + attemptedPosition = position; + attemptedDuration = duration; // Don't send progress if no duration (not ready) if (duration.inMilliseconds == 0) { @@ -197,6 +206,7 @@ class PlaybackProgressTracker { 'skipping next $_ticksToSkip tick(s)', error: e, ); + unawaited(_queueOnlineFailureProgress(position, duration)); }), ); } @@ -209,12 +219,33 @@ class PlaybackProgressTracker { 'skipping next $_ticksToSkip tick(s)', error: e, ); + await _queueOnlineFailureProgress( + attemptedPosition ?? player.state.position, + attemptedDuration ?? player.state.duration, + ); } else { appLogger.d('Failed to send progress update (non-critical)', error: e); } } } + Duration _clampPosition(Duration position, Duration duration) { + if (duration.inMilliseconds <= 0) return position; + if (position.isNegative) return Duration.zero; + if (position > duration) return duration; + return position; + } + + Future _queueOnlineFailureProgress(Duration position, Duration duration) async { + if (!queueOnOnlineFailure || offlineWatchService == null) return; + if (duration.inMilliseconds == 0) return; + try { + await _sendOfflineProgress(_clampPosition(position, duration), duration); + } catch (e) { + appLogger.d('Failed to queue fallback progress after online report failure', error: e); + } + } + void _resetBackoff() { if (_consecutiveFailures > 0) { _consecutiveFailures = 0; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 7ed2926d..ca45f8ee 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1610,6 +1610,9 @@ class PlexClient required int time, required String state, // 'playing', 'paused', 'stopped', 'buffering' int? duration, + bool offline = false, + DateTime? updatedAt, + bool? continuing, }) async { final response = await _http.post( '/:/timeline', @@ -1619,6 +1622,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, }, ); // Surface non-2xx instead of swallowing — progress is the cornerstone @@ -3922,7 +3928,18 @@ class PlexClient Duration? duration, String? playSessionId, String? mediaSourceId, - }) => updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); + bool offline = false, + DateTime? updatedAt, + bool? continuing, + }) => updateProgress( + itemId, + time: position.inMilliseconds, + state: 'stopped', + duration: duration?.inMilliseconds, + offline: offline, + updatedAt: updatedAt, + continuing: continuing, + ); // ── Downloads ──────────────────────────────────────────────────── diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index af859380..a41fd4d6 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -62,7 +62,10 @@ 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 mediaClient = isOffline ? null : manager.getClient(metadata.serverId ?? ''); + final serverId = metadata.serverId ?? ''; + final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(serverId)) + ? manager.getClient(serverId) + : null; int mediaIndex = selectedMediaIndex ?? 0; if (selectedMediaIndex == null) { @@ -87,7 +90,14 @@ Future navigateToVideoPlayer( final videoPath = await downloadProvider.getVideoFilePath(globalKey); if (videoPath != null && context.mounted) { final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; - launched = await ExternalPlayerService.launch(context: context, videoUrl: videoUrl); + launched = await ExternalPlayerService.launch( + context: context, + videoUrl: videoUrl, + metadata: metadata, + client: mediaClient, + mediaIndex: mediaIndex, + mediaSourceId: selectedMediaSourceId, + ); } } else if (context.mounted) { launched = await ExternalPlayerService.launch( diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index c9b525f2..ec62526b 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -731,6 +731,32 @@ void main() { expect(p.getMetadata('srv:absent'), isNull); p.dispose(); }); + + test('watched progress events mark downloaded metadata watched and clear resume', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + final item = MediaItem( + id: '42', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'srv', + durationMs: 100000, + viewOffsetMs: 12000, + viewCount: 0, + ); + p.debugSeedState(metadata: {'srv:42': item}); + + WatchStateNotifier().notifyProgress(item: item, viewOffset: 95000, duration: 100000, watchedThreshold: 0.9); + await Future.delayed(Duration.zero); + + final updated = p.getMetadata('srv:42'); + expect(updated?.isWatched, isTrue); + expect(updated?.viewOffsetMs, 0); + + p.dispose(); + }); }); group('DownloadProvider — progress stream', () { diff --git a/test/providers/offline_watch_provider_test.dart b/test/providers/offline_watch_provider_test.dart index d249e85f..6e13c1ad 100644 --- a/test/providers/offline_watch_provider_test.dart +++ b/test/providers/offline_watch_provider_test.dart @@ -64,6 +64,17 @@ void main() { p.dispose(); }); + test('getViewOffset returns null for local progress that crossed watched threshold', () async { + final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); + + await syncService.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000); + + expect(await p.isWatched('srv:42'), isTrue); + expect(await p.getViewOffset('srv:42'), isNull); + + p.dispose(); + }); + test('getNextUnwatchedEpisode returns null for show with no downloads', () async { final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); expect(await p.getNextUnwatchedEpisode('show-123'), isNull); diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart index e9e1374c..a834f6a6 100644 --- a/test/services/live_session_tracker_test.dart +++ b/test/services/live_session_tracker_test.dart @@ -45,6 +45,9 @@ class _FakeJellyfinClient implements JellyfinClient { Duration? duration, String? playSessionId, String? mediaSourceId, + bool offline = false, + DateTime? updatedAt, + bool? continuing, }) 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 1cfdf39c..ed9da17e 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -6,6 +6,10 @@ import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.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/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; @@ -59,6 +63,75 @@ class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource bool get hasListeners => super.hasListeners; } +class _RecordingMediaClient implements MediaServerClient { + _RecordingMediaClient({required this.serverId, required this.backend}); + + @override + final String serverId; + + @override + final MediaBackend backend; + + @override + double get watchedThreshold => 0.9; + + @override + 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 watched = []; + + @override + Future fetchItem(String id) async => + MediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId); + + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + started.add((itemId: itemId, positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); + } + + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + bool offline = false, + DateTime? updatedAt, + bool? continuing, + }) async { + stopped.add(( + itemId: itemId, + positionMs: position.inMilliseconds, + durationMs: duration?.inMilliseconds, + offline: offline, + updatedAt: updatedAt, + continuing: continuing, + )); + } + + @override + Future markWatched(MediaItem item) async { + watched.add(item.id); + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// Build a service against an in-memory database and a bare-metal /// [MultiServerManager] (no servers added). ({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() { @@ -262,6 +335,57 @@ void main() { expect(retained!.syncAttempts, OfflineWatchSyncService.maxSyncAttempts); expect(retained.lastError, 'server error'); }); + + test('partial Plex offline progress replays as offline stopped progress', () 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: 100000); + final queued = await db.getLatestWatchAction('srv:42'); + + await svc.syncPendingItems(); + + expect(client.started, hasLength(1)); + 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.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 { + 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: 95000, duration: 100000); + final queued = await db.getLatestWatchAction('srv:42'); + + await svc.syncPendingItems(); + + expect(client.started, isEmpty); + 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.watched, ['42']); + expect(await svc.getPendingSyncCount(), 0); + }); }); // ============================================================ @@ -646,7 +770,7 @@ void main() { ); expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue); - expect(await svc.getLocalViewOffset('jf-machine:item-1'), 90000); + 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.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 009614a2..3cc5497f 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -129,7 +129,15 @@ class _FakePlexClient implements PlexClient { Object? throwOnNextCall; @override - Future updateProgress(String ratingKey, {required int time, required String state, int? duration}) async { + Future updateProgress( + String ratingKey, { + required int time, + required String state, + int? duration, + bool offline = false, + DateTime? updatedAt, + bool? continuing, + }) async { if (throwOnNextCall != null) { final err = throwOnNextCall!; throwOnNextCall = null; @@ -193,6 +201,9 @@ class _FakePlexClient implements PlexClient { Duration? duration, String? playSessionId, String? mediaSourceId, + bool offline = false, + DateTime? updatedAt, + bool? continuing, }) { playbackSessionIds.add(playSessionId); playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null)); @@ -342,6 +353,23 @@ void main() { expect(call.duration, 100000); // 100s in ms }); + test('"stopped" can override stale player position for completion', () async { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(ratingKey: '42'), + player: player, + isOffline: false, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('stopped', positionOverride: const Duration(seconds: 100)); + + expect(client.updateProgressCalls.single.time, 100000); + expect(client.markWatchedCalls, ['42']); + }); + test('"playing" fires-and-forgets but eventually invokes updateProgress', () async { final client = _FakePlexClient(); final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); @@ -760,6 +788,34 @@ void main() { await tracker.sendProgress('playing'); expect(await svc.getPendingSyncCount(), 0); }); + + test('online local playback queues fallback progress when reporting fails', () async { + final (svc: svc, db: db, mgr: mgr) = await makeOfflineService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final client = _FakePlexClient()..throwOnNextCall = StateError('offline'); + final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(ratingKey: '42', serverId: 'srv'), + player: player, + isOffline: false, + offlineWatchService: svc, + queueOnOnlineFailure: true, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('stopped', positionOverride: const Duration(seconds: 100)); + + final action = await db.getLatestWatchAction('srv:42'); + expect(action, isNotNull); + expect(action!.viewOffset, 100000); + expect(action.shouldMarkWatched, isTrue); + }); }); // ============================================================ @@ -913,7 +969,15 @@ class _ScrobblePreciseClient implements PlexClient { int markWatchedSuccesses = 0; @override - Future updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {} + Future updateProgress( + String ratingKey, { + required int time, + required String state, + int? duration, + bool offline = false, + DateTime? updatedAt, + bool? continuing, + }) async {} @override Future reportPlaybackStarted({ @@ -947,6 +1011,9 @@ class _ScrobblePreciseClient implements PlexClient { Duration? duration, String? playSessionId, String? mediaSourceId, + bool offline = false, + DateTime? updatedAt, + bool? continuing, }) async {} @override diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart index 4589d51c..3855552d 100644 --- a/test/services/playback_report_session_test.dart +++ b/test/services/playback_report_session_test.dart @@ -47,6 +47,9 @@ class _RecordingClient implements MediaServerClient { Duration? duration, String? playSessionId, String? mediaSourceId, + bool offline = false, + DateTime? updatedAt, + bool? continuing, }) async { calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId'); if (failNextStop) {