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.
This commit is contained in:
edde746
2026-06-10 05:38:33 +02:00
parent 903b8eced8
commit 9b48f26463
10 changed files with 63 additions and 52 deletions
+35
View File
@@ -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<WatchStateStore, WatchStatePatch?>((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<WatchStateStore>().apply(item);
} on ProviderNotFoundException {
return item;
}
}
List<MediaItem> readFreshWatchStateAll(List<MediaItem> items) {
try {
return read<WatchStateStore>().applyAll(items);
} on ProviderNotFoundException {
return items;
}
}
}
+5 -1
View File
@@ -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<DiscoverScreen>
);
}
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();
@@ -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(
+2 -14
View File
@@ -386,21 +386,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
/// Session-fresh view of [item]: server snapshot + newest watch-state patch.
MediaItem _fresh(MediaItem item) {
try {
return context.read<WatchStateStore>().apply(item);
} on ProviderNotFoundException {
return item;
}
}
MediaItem _fresh(MediaItem item) => context.readFreshWatchState(item);
List<MediaItem> _freshAll(List<MediaItem> items) {
try {
return context.read<WatchStateStore>().applyAll(items);
} on ProviderNotFoundException {
return items;
}
}
List<MediaItem> _freshAll(List<MediaItem> items) => context.readFreshWatchStateAll(items);
MediaItem _normalizeRefreshedItem(MediaItem item, MediaItem fallback) {
return _withFallbackLibrary(
+1 -9
View File
@@ -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<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
MediaItem _effectiveItem(BuildContext context) {
try {
final patch = context.select<WatchStateStore, WatchStatePatch?>((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) {
+1 -5
View File
@@ -148,11 +148,7 @@ Future<bool?> navigateToVideoPlayer(
bool resolveWatchState = true,
}) async {
if (resolveWatchState) {
try {
metadata = context.read<WatchStateStore>().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<DownloadProvider>();
+1 -8
View File
@@ -58,14 +58,7 @@ class EpisodeCard extends StatefulWidget {
}
class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<EpisodeCard> {
MediaItem _effectiveEpisode(BuildContext context) {
try {
final patch = context.select<WatchStateStore, WatchStatePatch?>((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<String> qualityLabels) {
final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12);
+2 -13
View File
@@ -95,23 +95,12 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
Object _effectiveItem(BuildContext context) {
final item = widget.item;
if (item is! MediaItem) return item;
try {
final patch = context.select<WatchStateStore, WatchStatePatch?>((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<WatchStateStore>().apply(item);
} on ProviderNotFoundException {
return item;
}
return item is MediaItem ? context.readFreshWatchState(item) : item;
}
String _buildSemanticLabel(Object item) {
+9 -2
View File
@@ -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<MediaContextMenu> {
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;
+2
View File
@@ -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()