fix(playback): harden watch progress edge cases

This commit is contained in:
edde746
2026-05-29 21:16:01 +02:00
parent d93ea9813f
commit dba01f14bb
20 changed files with 593 additions and 163 deletions
+5 -5
View File
@@ -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 = <String, List<OfflineWatchProgressItem>>{};
@@ -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);
+5 -6
View File
@@ -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',
);
@@ -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<WatchStateEvent>? _subscription;
final Map<String, WatchStateOverlayPatch> _patches = {};
final Map<String, _WatchStateOverlayEntry> _patches = {};
String? _activeProfileId;
Map<String, String?> _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();
}
@@ -135,7 +135,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
Future<void> _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;
+4 -1
View File
@@ -437,7 +437,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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;
+66 -33
View File
@@ -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<void> 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<void> _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,
);
}
+12 -6
View File
@@ -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);
@@ -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',
@@ -174,7 +174,6 @@ class PlaybackProgressTracker {
void resumeAfterStoppedReport() {
_stoppedProgressFuture = null;
_stopProgressNotified = false;
_reportSession?.resetAfterStop();
}
+13 -1
View File
@@ -70,15 +70,21 @@ class PlaybackReportSession {
_PendingProgressReport? _pendingProgress;
Future<void>? _pumpFuture;
Future<void>? _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;
}
}
+6 -4
View File
@@ -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({
+23 -35
View File
@@ -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<OfflineWatchProgressItem> 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(),
};
}
}