@@ -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<void> 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 <String, String>{};
|
||||
final streamHeaders = mediaClient?.streamHeaders;
|
||||
final offlineWatchService = context.read<OfflineWatchSyncService>();
|
||||
final userProfileProvider = context.read<UserProfileProvider>();
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
@@ -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,
|
||||
|
||||
@@ -5,8 +5,8 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
|
||||
Future<void> _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<void> _loadAdjacentEpisodes() async {
|
||||
if (!mounted || widget.isLive) return;
|
||||
|
||||
if (_isOfflinePlayback) {
|
||||
if (widget.isOffline) {
|
||||
// Offline mode: find next/previous from downloaded episodes
|
||||
_loadAdjacentEpisodesOffline();
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OfflineWatchSyncService>();
|
||||
|
||||
// Initialize media controls manager (must exist before the per-item
|
||||
|
||||
@@ -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<AppDatabase>(),
|
||||
@@ -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;
|
||||
|
||||
@@ -420,6 +420,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
return context.read<MultiServerProvider>().serverManager.getClient(id);
|
||||
}
|
||||
|
||||
MediaServerClient? _getOnlineMediaServerClient(BuildContext context) {
|
||||
final id = _currentMetadata.serverId;
|
||||
if (id == null) return null;
|
||||
final manager = context.read<MultiServerProvider>().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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
|
||||
String get playbackSessionIdentifier => _playbackSessionIdentifier;
|
||||
String get playbackTranscodeSessionId => _playbackTranscodeSessionId;
|
||||
|
||||
Future<void> _sendStoppedProgressOnce() {
|
||||
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
|
||||
final existing = _stoppedProgressFuture;
|
||||
if (existing != null) return existing;
|
||||
|
||||
final tracker = _progressTracker;
|
||||
if (tracker == null) return Future<void>.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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -191,6 +191,7 @@ class PlaybackInitializationService {
|
||||
mediaInfo: mediaInfo,
|
||||
externalSubtitles: sidecarSubtitles,
|
||||
isOffline: true,
|
||||
playMethod: 'DirectPlay',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> sendProgress(String state) async {
|
||||
await _sendProgress(state);
|
||||
/// [state] can be 'playing', 'paused', or 'stopped'.
|
||||
Future<void> sendProgress(String state, {Duration? positionOverride}) async {
|
||||
await _sendProgress(state, positionOverride: positionOverride);
|
||||
}
|
||||
|
||||
Future<void> _sendProgress(String state) async {
|
||||
Future<void> _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<void> _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;
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -62,7 +62,10 @@ Future<bool?> 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<MultiServerProvider>().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<bool?> 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(
|
||||
|
||||
@@ -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<void>.delayed(Duration.zero);
|
||||
|
||||
final updated = p.getMetadata('srv:42');
|
||||
expect(updated?.isWatched, isTrue);
|
||||
expect(updated?.viewOffsetMs, 0);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('DownloadProvider — progress stream', () {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 = <String>[];
|
||||
|
||||
@override
|
||||
Future<MediaItem?> fetchItem(String id) async =>
|
||||
MediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId);
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<void> 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);
|
||||
});
|
||||
|
||||
@@ -129,7 +129,15 @@ class _FakePlexClient implements PlexClient {
|
||||
Object? throwOnNextCall;
|
||||
|
||||
@override
|
||||
Future<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {
|
||||
Future<void> 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<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {}
|
||||
Future<void> updateProgress(
|
||||
String ratingKey, {
|
||||
required int time,
|
||||
required String state,
|
||||
int? duration,
|
||||
bool offline = false,
|
||||
DateTime? updatedAt,
|
||||
bool? continuing,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackStarted({
|
||||
@@ -947,6 +1011,9 @@ class _ScrobblePreciseClient implements PlexClient {
|
||||
Duration? duration,
|
||||
String? playSessionId,
|
||||
String? mediaSourceId,
|
||||
bool offline = false,
|
||||
DateTime? updatedAt,
|
||||
bool? continuing,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user