From 9b48f26463e3287d5787880664072a49a4a792fd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:38:33 +0200 Subject: [PATCH] refactor(watch): shared point-of-use resolution across consumers Adds context.withFreshWatchState/readFreshWatchState and sweeps the remaining session-critical readers: context menu actions, folder tree rows, discover hero, and TV spotlight minutes-left. --- lib/providers/watch_state_store.dart | 35 ++++++++++++++++++++ lib/screens/discover_screen.dart | 6 +++- lib/screens/libraries/folder_tree_item.dart | 5 +++ lib/screens/media_detail_screen.dart | 16 ++------- lib/screens/playlist/playlist_item_card.dart | 10 +----- lib/utils/video_player_navigation.dart | 6 +--- lib/widgets/episode_card.dart | 9 +---- lib/widgets/media_card.dart | 15 ++------- lib/widgets/media_context_menu.dart | 11 ++++-- lib/widgets/tv_spotlight_background.dart | 2 ++ 10 files changed, 63 insertions(+), 52 deletions(-) diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index 34748af1..d3a15069 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -2,6 +2,8 @@ import 'dart:async'; import '../media/ids.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; import '../media/media_item.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; @@ -148,3 +150,36 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin super.dispose(); } } + +/// Point-of-use watch-state resolution. All fall back to the item as-is when +/// no [WatchStateStore] is in the tree (tests, isolated subtrees). +extension WatchStateResolution on BuildContext { + /// Build-time resolution: subscribes this context to the item's effective + /// patch, so the widget rebuilds when a newer event lands for it (or an + /// ancestor). Use in `build`. + MediaItem withFreshWatchState(MediaItem item) { + try { + final patch = select((store) => store.patchForItem(item)); + return WatchStateStore.applyPatch(item, patch); + } on ProviderNotFoundException { + return item; + } + } + + /// Point-in-time resolution for handlers and non-build code paths. + MediaItem readFreshWatchState(MediaItem item) { + try { + return read().apply(item); + } on ProviderNotFoundException { + return item; + } + } + + List readFreshWatchStateAll(List items) { + try { + return read().applyAll(items); + } on ProviderNotFoundException { + return items; + } + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 38f24ddf..a6c23dd6 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -27,6 +27,7 @@ import '../providers/multi_server_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../providers/watch_state_store.dart'; import '../widgets/hub_section.dart'; import '../widgets/app_menu.dart'; import '../widgets/clickable_cursor.dart'; @@ -2082,7 +2083,10 @@ class _DiscoverScreenState extends State ); } - Widget _buildSmartPlayButton(MediaItem heroItem) { + Widget _buildSmartPlayButton(MediaItem rawHeroItem) { + // The on-deck snapshot refetches shortly after a watch event; the store + // patch bridges the gap so "minutes left" never lags. + final heroItem = context.withFreshWatchState(rawHeroItem); final hasProgress = heroItem.hasActiveProgress; final isTv = PlatformDetector.isTV(); diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index e7902950..e601f2db 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_button.dart'; +import '../../providers/watch_state_store.dart'; import '../../focus/focusable_wrapper.dart'; import '../../media/media_item.dart'; import '../../media/media_item_types.dart'; @@ -72,6 +73,8 @@ class FolderTreeItem extends StatelessWidget { return item.displayTitle; } + MediaItem _effectiveItem(BuildContext context) => context.withFreshWatchState(item); + String? _dedupeSubtitle(String? subtitle) { final value = subtitle?.trim(); if (value == null || value.isEmpty || value == _rowTitle()) return null; @@ -271,6 +274,8 @@ class FolderTreeItem extends StatelessWidget { } Widget _buildWatchOverlay(BuildContext context, bool showUnwatchedCount) { + // Shadows the field with the session-fresh view; everything below reads it. + final item = _effectiveItem(context); final hasActiveProgress = item.hasActiveProgress; return Stack( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 424ecded..280f6868 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -386,21 +386,9 @@ class _MediaDetailScreenState extends State } /// Session-fresh view of [item]: server snapshot + newest watch-state patch. - MediaItem _fresh(MediaItem item) { - try { - return context.read().apply(item); - } on ProviderNotFoundException { - return item; - } - } + MediaItem _fresh(MediaItem item) => context.readFreshWatchState(item); - List _freshAll(List items) { - try { - return context.read().applyAll(items); - } on ProviderNotFoundException { - return items; - } - } + List _freshAll(List items) => context.readFreshWatchStateAll(items); MediaItem _normalizeRefreshedItem(MediaItem item, MediaItem fallback) { return _withFallbackLibrary( diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 04866088..18236a89 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import '../../media/ids.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../mixins/context_menu_tap_mixin.dart'; @@ -47,14 +46,7 @@ class PlaylistItemCard extends StatefulWidget { } class _PlaylistItemCardState extends State with ContextMenuTapMixin { - MediaItem _effectiveItem(BuildContext context) { - try { - final patch = context.select((store) => store.patchForItem(widget.item)); - return WatchStateStore.applyPatch(widget.item, patch); - } on ProviderNotFoundException { - return widget.item; - } - } + MediaItem _effectiveItem(BuildContext context) => context.withFreshWatchState(widget.item); @override Widget build(BuildContext context) { diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 2b7ec314..0b525c04 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -148,11 +148,7 @@ Future navigateToVideoPlayer( 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. - } + metadata = context.readFreshWatchState(metadata); } final navigator = Navigator.of(context); final downloadProvider = context.read(); diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 5c250fba..4ecefa4a 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -58,14 +58,7 @@ class EpisodeCard extends StatefulWidget { } class _EpisodeCardState extends State with ContextMenuTapMixin { - MediaItem _effectiveEpisode(BuildContext context) { - try { - final patch = context.select((store) => store.patchForItem(widget.episode)); - return WatchStateStore.applyPatch(widget.episode, patch); - } on ProviderNotFoundException { - return widget.episode; - } - } + MediaItem _effectiveEpisode(BuildContext context) => context.withFreshWatchState(widget.episode); Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode, List qualityLabels) { final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 0da65580..705831a7 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -95,23 +95,12 @@ class MediaCardState extends State with ContextMenuTapMixin((store) => store.patchForItem(item)); - return WatchStateStore.applyPatch(item, patch); - } on ProviderNotFoundException { - return item; - } + return item is MediaItem ? context.withFreshWatchState(item) : item; } Object _effectiveItemForAction(BuildContext context) { final item = widget.item; - if (item is! MediaItem) return item; - try { - return context.read().apply(item); - } on ProviderNotFoundException { - return item; - } + return item is MediaItem ? context.readFreshWatchState(item) : item; } String _buildSemanticLabel(Object item) { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 8021bb57..86c7a657 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; + +import '../providers/watch_state_store.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -130,8 +132,13 @@ class MediaContextMenuState extends State { widget.onListRefresh?.call(); } - /// The widget's [item] cast as a [MediaItem]. Returns `null` for playlists. - MediaItem? get _mediaItem => widget.item is MediaItem ? widget.item as MediaItem : null; + /// The widget's [item] cast as a [MediaItem], resolved against the session + /// watch-state store so the offered actions match what the card shows. + /// Returns `null` for playlists. + MediaItem? get _mediaItem { + final item = widget.item; + return item is MediaItem ? context.readFreshWatchState(item) : null; + } /// The widget's [item] cast as a [MediaPlaylist]. Returns `null` for media items. MediaPlaylist? get _playlist => widget.item is MediaPlaylist ? widget.item as MediaPlaylist : null; diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 57a316a8..572121ba 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -8,6 +8,7 @@ import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; +import '../providers/watch_state_store.dart'; import '../services/image_cache_service.dart'; import '../utils/content_utils.dart'; import '../utils/formatters.dart'; @@ -342,6 +343,7 @@ class TvSpotlightBackground extends StatelessWidget { Widget _buildPrimaryAction(BuildContext context, MediaItem media) { final scale = _scale(context); + media = context.withFreshWatchState(media); final hasProgress = media.hasActiveProgress; final minutesLeft = hasProgress && media.durationMs != null && media.viewOffsetMs != null ? ((media.durationMs! - media.viewOffsetMs!) / 60_000).round()