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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user