From 3753967caf166d91cb3aee7803c7c33ce4af8803 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:23:09 +0200 Subject: [PATCH] fix(watch): resolve watch state per item at cards and playback open Cards consult the hierarchy-aware store so container marks flip episode rows instantly and a stale per-episode patch can no longer shadow a newer show/season mark; playback opens with the session-fresh resume offset. --- lib/screens/playlist/playlist_item_card.dart | 4 +- lib/utils/video_player_navigation.dart | 12 + lib/widgets/episode_card.dart | 4 +- lib/widgets/media_card.dart | 4 +- lib/widgets/media_context_menu.dart | 6 +- pubspec.yaml | 2 +- test/screens/media_detail_screen_test.dart | 236 +++++++++++++++++++ 7 files changed, 257 insertions(+), 11 deletions(-) diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 213a1a52..04866088 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -49,9 +49,7 @@ class PlaylistItemCard extends StatefulWidget { class _PlaylistItemCardState extends State with ContextMenuTapMixin { MediaItem _effectiveItem(BuildContext context) { try { - final patch = context.select( - (provider) => provider.patchForGlobalKey(widget.item.globalKey), - ); + final patch = context.select((store) => store.patchForItem(widget.item)); return WatchStateStore.applyPatch(widget.item, patch); } on ProviderNotFoundException { return widget.item; diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index a6dc53db..2b7ec314 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -10,6 +10,7 @@ import '../mpv/mpv.dart'; import '../models/transcode_quality_preset.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; +import '../providers/watch_state_store.dart'; import '../screens/video_player_screen.dart'; import '../services/external_player_service.dart'; import '../services/offline_watch_sync_service.dart'; @@ -127,6 +128,9 @@ Future saveMediaVersionIndexFor(MediaItem metadata, int index) async { /// - [usePushReplacement]: If true, replaces current route instead of pushing; /// useful for episode-to-episode navigation. Defaults to false. /// - [isOffline]: If true, plays from downloaded content without requiring server connection. +/// - [resolveWatchState]: Resolve [metadata] through [WatchStateStore] so the +/// resume offset/watched flag are session-fresh even when the caller holds a +/// stale list snapshot. Pass false for explicit intents like play-from-start. /// /// Returns a Future that completes with a boolean indicating whether the content /// was watched, or null if navigation was cancelled. @@ -141,7 +145,15 @@ Future navigateToVideoPlayer( TranscodeQualityPreset? selectedQualityPreset, bool usePushReplacement = false, bool isOffline = false, + bool resolveWatchState = true, }) async { + if (resolveWatchState) { + try { + metadata = context.read().apply(metadata); + } on ProviderNotFoundException { + // Tests or trees without the store play the snapshot as-is. + } + } final navigator = Navigator.of(context); final downloadProvider = context.read(); // Use the manager-routed lookup so Jellyfin items don't trip the diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index a824b667..5c250fba 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -60,9 +60,7 @@ class EpisodeCard extends StatefulWidget { class _EpisodeCardState extends State with ContextMenuTapMixin { MediaItem _effectiveEpisode(BuildContext context) { try { - final patch = context.select( - (provider) => provider.patchForGlobalKey(widget.episode.globalKey), - ); + final patch = context.select((store) => store.patchForItem(widget.episode)); return WatchStateStore.applyPatch(widget.episode, patch); } on ProviderNotFoundException { return widget.episode; diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 4062480c..0da65580 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -97,9 +97,7 @@ class MediaCardState extends State with ContextMenuTapMixin( - (provider) => provider.patchForGlobalKey(item.globalKey), - ); + final patch = context.select((store) => store.patchForItem(item)); return WatchStateStore.applyPatch(item, patch); } on ProviderNotFoundException { return item; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 6934cf2e..8021bb57 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -526,7 +526,11 @@ class MediaContextMenuState extends State { case 'play_from_beginning': didNavigate = true; if (context.mounted) { - await navigateToVideoPlayer(context, metadata: mediaItem!.copyWith(viewOffsetMs: 0)); + await navigateToVideoPlayer( + context, + metadata: mediaItem!.copyWith(viewOffsetMs: 0), + resolveWatchState: false, + ); } break; diff --git a/pubspec.yaml b/pubspec.yaml index 68e454ce..89449157 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plezy description: "A beautiful Plex and Jellyfin client for Flutter" publish_to: "none" -version: 2.5.0+110 +version: 2.5.1+111 environment: sdk: ">=3.12.0 <4.0.0" diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 4e185e20..972ddcac 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'package:drift/native.dart'; import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; @@ -11,15 +14,24 @@ import 'package:plezy/media/media_hub.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/server_capabilities.dart'; +import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/providers/watch_state_store.dart'; import 'package:plezy/screens/media_detail_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/layout_constants.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; +import 'package:plezy/widgets/episode_card.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:provider/provider.dart'; @@ -514,6 +526,227 @@ void main() { expect(find.text('Episode 2'), findsOneWidget); }); + + group('watch state freshness (phone layout)', () { + MediaItem buildShow() => MediaItem( + id: 'show_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + title: 'The Show', + leafCount: 4, + viewedLeafCount: 0, + serverId: 'server_1', + serverName: 'Server', + ); + + MediaItem buildSeason(MediaItem show, int index) => MediaItem( + id: 'season_$index', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season $index', + index: index, + leafCount: 2, + viewedLeafCount: 0, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + + MediaItem buildEpisode(MediaItem show, MediaItem season, int index) => MediaItem( + id: '${season.id}_episode_$index', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode S${season.index}E$index', + index: index, + durationMs: 30 * 60 * 1000, + parentId: season.id, + parentIndex: season.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + + Future pumpPhoneDetail(WidgetTester tester, _FakeMediaServerClient client, MediaItem show) async { + TvDetectionService.debugSetAppleTVOverride(false); + await SettingsService.getInstance(); + tester.view.physicalSize = const Size(1100, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + final downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); + downloadManager.recoveryFuture = Future.value(); + final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await downloadProvider.ensureInitialized(); + + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final watchStateOverlay = WatchStateStore(); + + addTearDown(() async { + watchStateOverlay.dispose(); + downloadProvider.dispose(); + downloadManager.dispose(); + multiServerProvider.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: watchStateOverlay), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: MediaDetailScreen(metadata: show), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + Finder episodeCardFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(EpisodeCard)); + + bool episodeRowWatched(WidgetTester tester, String title) { + final card = episodeCardFor(title); + expect(card, findsOneWidget, reason: 'episode row "$title" should be visible'); + return tester.any(find.descendant(of: card, matching: find.byIcon(Symbols.check_rounded))); + } + + bool episodeRowHasProgress(WidgetTester tester, String title) { + final card = episodeCardFor(title); + expect(card, findsOneWidget, reason: 'episode row "$title" should be visible'); + return tester.any(find.descendant(of: card, matching: find.byType(LinearProgressIndicator))); + } + + Future emit(WidgetTester tester, void Function() send) async { + send(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + testWidgets('marking the show watched flips every visible episode row', (tester) async { + final show = buildShow(); + final season1 = buildSeason(show, 1); + final season2 = buildSeason(show, 2); + final episodes = [buildEpisode(show, season1, 1), buildEpisode(show, season1, 2)]; + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season1, season2], + season1.id: episodes, + season2.id: [buildEpisode(show, season2, 1), buildEpisode(show, season2, 2)], + }, + ); + + await pumpPhoneDetail(tester, client, show); + expect(episodeRowWatched(tester, 'Episode S1E1'), isFalse); + expect(episodeRowWatched(tester, 'Episode S1E2'), isFalse); + + await emit(tester, () => WatchStateNotifier().notifyWatched(item: show, isNowWatched: true)); + + expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue); + expect(episodeRowWatched(tester, 'Episode S1E2'), isTrue); + }); + + testWidgets('container mark overrides an older per-episode patch', (tester) async { + final show = buildShow(); + final season1 = buildSeason(show, 1); + final episode1 = buildEpisode(show, season1, 1); + final episode2 = buildEpisode(show, season1, 2); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season1, buildSeason(show, 2)], + season1.id: [episode1, episode2], + }, + ); + + await pumpPhoneDetail(tester, client, show); + + // Seed a session patch for one episode (e.g. user toggled it earlier). + await emit(tester, () => WatchStateNotifier().notifyWatched(item: episode1, isNowWatched: false)); + expect(episodeRowWatched(tester, 'Episode S1E1'), isFalse); + + await emit(tester, () => WatchStateNotifier().notifyWatched(item: show, isNowWatched: true)); + + expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue); + expect(episodeRowWatched(tester, 'Episode S1E2'), isTrue); + }); + + testWidgets('marking a season watched flips its episode rows', (tester) async { + final show = buildShow(); + final season1 = buildSeason(show, 1); + final season2 = buildSeason(show, 2); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season1, season2], + season1.id: [buildEpisode(show, season1, 1), buildEpisode(show, season1, 2)], + season2.id: [buildEpisode(show, season2, 1)], + }, + ); + + await pumpPhoneDetail(tester, client, show); + + await emit(tester, () => WatchStateNotifier().notifyWatched(item: season1, isNowWatched: true)); + + expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue); + expect(episodeRowWatched(tester, 'Episode S1E2'), isTrue); + }); + + testWidgets('container mark clears progress, including after a season tab round-trip', (tester) async { + final show = buildShow(); + final season1 = buildSeason(show, 1); + final season2 = buildSeason(show, 2); + final episode1 = buildEpisode(show, season1, 1); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season1, season2], + season1.id: [episode1, buildEpisode(show, season1, 2)], + season2.id: [buildEpisode(show, season2, 1)], + }, + ); + + await pumpPhoneDetail(tester, client, show); + + // Played partway earlier in the session. + await emit( + tester, + () => WatchStateNotifier().notifyProgress(item: episode1, viewOffset: 600000, duration: 1800000), + ); + expect(episodeRowHasProgress(tester, 'Episode S1E1'), isTrue); + + await emit(tester, () => WatchStateNotifier().notifyWatched(item: show, isNowWatched: true)); + expect(episodeRowHasProgress(tester, 'Episode S1E1'), isFalse); + expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue); + + // Round-trip through another season tab; the cached page restore must not + // resurrect the dead progress offset. + await tester.tap(find.text('Season 2')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Season 1')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(episodeRowHasProgress(tester, 'Episode S1E1'), isFalse); + expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue); + }); + }); } class _FakeMediaServerClient implements MediaServerClient { @@ -541,6 +774,9 @@ class _FakeMediaServerClient implements MediaServerClient { @override MediaBackend get backend => MediaBackend.jellyfin; + @override + ServerCapabilities get capabilities => ServerCapabilities.jellyfin; + @override Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async { return (item: show, onDeckEpisode: null);