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