From 7a3f63683e3d485fcb0847a0ebe207380c01d80c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 9 May 2026 06:00:36 +0200 Subject: [PATCH] fix(ui): refresh stale watch state --- lib/main.dart | 9 ++ .../watch_state_overlay_provider.dart | 99 +++++++++++++ lib/screens/actor_media_screen.dart | 3 + .../base_media_list_detail_screen.dart | 10 +- lib/screens/collection_detail_screen.dart | 3 + lib/screens/discover_screen.dart | 14 +- lib/screens/hub_detail_screen.dart | 32 ++++- .../libraries/tabs/library_browse_tab.dart | 56 ++++++-- .../tabs/library_recommended_tab.dart | 76 +++++++++- lib/screens/media_detail/action_buttons.dart | 2 - lib/screens/media_detail_screen.dart | 130 +++++++----------- .../playlist/playlist_detail_screen.dart | 3 + lib/screens/playlist/playlist_item_card.dart | 54 +++++--- lib/screens/search_screen.dart | 1 + lib/services/playback_progress_tracker.dart | 52 ++++--- lib/services/plex_client.dart | 5 +- lib/services/trakt/trakt_sync_service.dart | 2 +- lib/utils/watch_state_notifier.dart | 16 ++- lib/widgets/episode_card.dart | 80 ++++++----- lib/widgets/media_card.dart | 55 +++++--- .../watch_state_overlay_provider_test.dart | 65 +++++++++ 21 files changed, 572 insertions(+), 195 deletions(-) create mode 100644 lib/providers/watch_state_overlay_provider.dart create mode 100644 test/providers/watch_state_overlay_provider_test.dart diff --git a/lib/main.dart b/lib/main.dart index e65d236a..77c99dcd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -48,6 +48,7 @@ import 'providers/playback_state_provider.dart'; import 'providers/download_provider.dart'; import 'providers/offline_mode_provider.dart'; import 'providers/offline_watch_provider.dart'; +import 'providers/watch_state_overlay_provider.dart'; import 'providers/companion_remote_provider.dart'; import 'providers/shader_provider.dart'; import 'utils/snackbar_helper.dart'; @@ -709,6 +710,14 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, ), + ChangeNotifierProxyProvider( + create: (_) => WatchStateOverlayProvider(), + update: (_, activeProfile, previous) { + final provider = previous ?? WatchStateOverlayProvider(); + provider.setActiveProfileId(activeProfile.activeId); + return provider; + }, + ), ChangeNotifierProxyProvider( create: (context) { final offlineModeProvider = context.read(); diff --git a/lib/providers/watch_state_overlay_provider.dart b/lib/providers/watch_state_overlay_provider.dart new file mode 100644 index 00000000..014514e8 --- /dev/null +++ b/lib/providers/watch_state_overlay_provider.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../media/media_item.dart'; +import '../mixins/disposable_change_notifier_mixin.dart'; +import '../utils/watch_state_notifier.dart'; + +@immutable +class WatchStateOverlayPatch { + final bool? isWatched; + final bool hasViewOffsetMs; + final int? viewOffsetMs; + + const WatchStateOverlayPatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs}); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WatchStateOverlayPatch && + other.isWatched == isWatched && + other.hasViewOffsetMs == hasViewOffsetMs && + other.viewOffsetMs == viewOffsetMs; + + @override + int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); +} + +/// Session-local watch-state overlay for immediate UI freshness. +/// +/// Server fetches remain the source of truth; this only patches stale +/// [MediaItem] snapshots while a screen waits for its next refresh. +class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNotifierMixin { + WatchStateOverlayProvider() { + _subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent); + } + + StreamSubscription? _subscription; + final Map _patches = {}; + String? _activeProfileId; + + WatchStateOverlayPatch? patchForGlobalKey(String globalKey) => _patches[globalKey]; + + WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey); + + MediaItem apply(MediaItem item) { + return applyPatch(item, patchForItem(item)); + } + + static MediaItem applyPatch(MediaItem item, WatchStateOverlayPatch? patch) { + if (patch == null) return item; + + return item.copyWith( + viewCount: patch.isWatched == null ? null : (patch.isWatched! ? 1 : 0), + viewOffsetMs: patch.hasViewOffsetMs ? patch.viewOffsetMs : null, + ); + } + + void setActiveProfileId(String? profileId) { + if (_activeProfileId == profileId) return; + _activeProfileId = profileId; + if (_patches.isEmpty) return; + _patches.clear(); + safeNotifyListeners(); + } + + void _onWatchStateEvent(WatchStateEvent event) { + final patch = switch (event.changeType) { + WatchStateChangeType.watched => const WatchStateOverlayPatch( + isWatched: true, + hasViewOffsetMs: true, + viewOffsetMs: 0, + ), + WatchStateChangeType.unwatched => const WatchStateOverlayPatch( + isWatched: false, + hasViewOffsetMs: true, + viewOffsetMs: 0, + ), + WatchStateChangeType.progressUpdate => WatchStateOverlayPatch( + hasViewOffsetMs: event.viewOffset != null, + viewOffsetMs: event.viewOffset, + ), + WatchStateChangeType.removedFromContinueWatching => null, + }; + + if (patch == null) return; + + if (_patches[event.globalKey] == patch) return; + _patches[event.globalKey] = patch; + safeNotifyListeners(); + } + + @override + void dispose() { + _subscription?.cancel(); + _subscription = null; + super.dispose(); + } +} diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 7d9f3142..2a0a7c9c 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -57,6 +57,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen serverName: widget.serverName, ); + @override + String? get itemServerId => widget.serverId; + @override String get title => widget.actorName; diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index b68dbd7d..3fd13b4d 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -98,13 +98,9 @@ abstract class BaseMediaListDetailScreen extends State @override void updateItemInLists(String itemId, MediaItem updatedItem) { - if (mounted) { - setState(() { - final index = items.indexWhere((it) => it.id == itemId); - if (index != -1) { - items[index] = updatedItem; - } - }); + final index = items.indexWhere((it) => it.id == itemId); + if (index != -1) { + items[index] = updatedItem; } } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 79b09898..5b15432e 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -38,6 +38,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen widget.collection; + @override + String? get itemServerId => widget.collection.serverId; + @override String get title => widget.collection.title!; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index b535c324..8d9cd5ab 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -164,8 +164,20 @@ class _DiscoverScreenState extends State @override void onWatchStateChanged(WatchStateEvent event) { + if (event.changeType == WatchStateChangeType.removedFromContinueWatching) { + _removeContinueWatchingItem(event.itemId); + unawaited(_refreshContinueWatching()); + return; + } + // Refresh continue watching when any relevant item changes - _refreshContinueWatching(); + unawaited(_refreshContinueWatching()); + } + + void _removeContinueWatchingItem(String itemId) { + setState(() { + _onDeck.removeWhere((item) => item.id == itemId); + }); } // Track initial load so we can focus hero when content first appears diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index bc07e8ac..10ba9fdd 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -256,13 +256,31 @@ class _HubDetailScreenState extends State } } - void _handleItemRefresh(String ratingKey) { - setState(() { - final index = _items.indexWhere((item) => item.id == ratingKey); - if (index != -1) { - appLogger.d('Item refresh requested for: $ratingKey'); - } - }); + Future _handleItemRefresh(String ratingKey) async { + final itemIndex = _items.indexWhere((item) => item.id == ratingKey); + final filteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey); + final existing = itemIndex != -1 + ? _items[itemIndex] + : filteredIndex != -1 + ? _filteredItems[filteredIndex] + : null; + if (existing == null) return; + final serverId = existing.serverId ?? widget.hub.serverId; + if (serverId == null) return; + + try { + final updated = await context.tryGetMediaClientForServer(serverId)?.fetchItem(ratingKey); + if (updated == null || !mounted) return; + setState(() { + final currentItemIndex = _items.indexWhere((item) => item.id == ratingKey); + if (currentItemIndex != -1) _items[currentItemIndex] = updated; + final currentFilteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey); + if (currentFilteredIndex != -1) _filteredItems[currentFilteredIndex] = updated; + }); + if (_selectedSort != null) _applySort(); + } catch (e) { + appLogger.d('Item refresh skipped for: $ratingKey', error: e); + } } @override diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 7e25e440..08956a16 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -46,11 +46,13 @@ import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart'; import '../../../mixins/grid_focus_node_mixin.dart'; import '../../../mixins/item_updatable.dart'; +import '../../../mixins/watch_state_aware.dart'; import '../../../mixins/deletion_aware.dart'; import '../../../mixins/paginated_item_loader.dart'; import '../../../widgets/skeleton_media_card.dart'; import '../../../utils/deletion_notifier.dart'; import '../../../utils/global_key_utils.dart'; +import '../../../utils/watch_state_notifier.dart'; import '../../../utils/platform_detector.dart'; import '../../../i18n/strings.g.dart'; import '../../main_screen.dart'; @@ -81,7 +83,13 @@ class LibraryBrowseTab extends BaseLibraryTab { } class _LibraryBrowseTabState extends BaseLibraryTabState - with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware, PaginatedItemLoader { + with + ItemUpdatable, + LibraryTabFocusMixin, + GridFocusNodeMixin, + WatchStateAware, + DeletionAware, + PaginatedItemLoader { @override String? get itemServerId => widget.library.serverId; @@ -91,6 +99,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.serverId; + @override + String? get watchStateServerId => widget.library.serverId; + + @override + Set? get watchedIds => loadedItems.values.map((e) => e.id).toSet(); + + @override + Set? get watchedGlobalKeys { + if (loadedItems.isEmpty) return {}; + + final keys = {}; + for (final item in loadedItems.values) { + final serverId = item.serverId ?? widget.library.serverId; + if (serverId == null) return null; + keys.add(_toGlobalKey(item.id, serverId: serverId)); + } + return keys; + } + @override Set? get deletionIds => loadedItems.values.map((e) => e.id).toSet(); @@ -107,6 +134,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { State createState() => _LibraryRecommendedTabState(); } -class _LibraryRecommendedTabState extends BaseLibraryTabState with ItemUpdatable { +class _LibraryRecommendedTabState extends BaseLibraryTabState + with ItemUpdatable, WatchStateAware { /// GlobalKeys for each hub section to enable vertical navigation final List> _hubKeys = []; @override String? get itemServerId => widget.library.serverId; + @override + String? get watchStateServerId => widget.library.serverId; + + @override + Set? get watchedIds { + final keys = {}; + for (final hub in items) { + for (final item in hub.items) { + keys.add(item.id); + if (item.parentId != null) keys.add(item.parentId!); + if (item.grandparentId != null) keys.add(item.grandparentId!); + } + } + return keys; + } + + @override + Set? get watchedGlobalKeys { + final keys = {}; + for (final hub in items) { + for (final item in hub.items) { + final serverId = item.serverId ?? widget.library.serverId; + if (serverId == null) return null; + keys.add(buildGlobalKey(serverId, item.id)); + if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!)); + if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!)); + } + } + return keys; + } + @override void updateItemInLists(String itemId, MediaItem updatedItem) { // Update the item in any hub that contains it. MediaHub items are @@ -48,6 +85,43 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState{}; + for (final hub in items) { + for (final item in hub.items) { + if (affectedIds.contains(item.id)) { + refreshIds.add(item.id); + } + } + } + for (final itemId in refreshIds) { + unawaited(updateItem(itemId)); + } + } + + void _removeContinueWatchingItem(String itemId) { + setState(() { + for (var i = 0; i < items.length; i++) { + final hub = items[i]; + if (!_isContinueWatchingHub(hub)) continue; + final newItems = hub.items.where((item) => item.id != itemId).toList(); + if (newItems.length != hub.items.length) { + items[i] = hub.copyWith(items: newItems, size: newItems.length); + } + } + }); + } + @override IconData get emptyIcon => Symbols.recommend_rounded; diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index faf159f4..6b5f53dd 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -174,8 +174,6 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, ); - unawaited(_updateWatchStateOffline()); - unawaited(_loadOfflineOnDeckEpisode()); } } else { // Online mode: dispatch via the right backend's neutral method so diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 165b8c61..e1c36a64 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -34,7 +34,6 @@ import '../utils/media_image_helper.dart'; import '../services/plex_client.dart'; import '../media/media_server_client.dart'; import '../services/media_list_playback_launcher.dart'; -import '../services/offline_watch_sync_service.dart'; import '../utils/content_utils.dart'; import '../utils/rating_utils.dart'; import '../models/download_models.dart'; @@ -192,41 +191,61 @@ class _MediaDetailScreenState extends State @override void onWatchStateChanged(WatchStateEvent event) { + _watchStateChanged = true; final epIndex = _episodes.indexWhere((e) => e.id == event.itemId); - if (event.changeType == WatchStateChangeType.progressUpdate && event.viewOffset != null) { - _patchLocalProgress(event.itemId, event.viewOffset!, epIndex: epIndex); - } else { - _localProgressById.remove(event.itemId); + if (event.changeType == WatchStateChangeType.removedFromContinueWatching) { + return; } + if (event.changeType == WatchStateChangeType.progressUpdate) { + if (event.viewOffset != null) { + _patchLocalProgress(event.itemId, event.viewOffset!, epIndex: epIndex); + } + if (event.isNowWatched != true) return; + } + + _localProgressById.remove(event.itemId); + _patchWatchedStateFromEvent( + event, + epIndex: epIndex, + clearWatchedProgress: !widget.isOffline || event.changeType == WatchStateChangeType.progressUpdate, + ); + if (widget.isOffline) { - // Offline: skip network refetch — patch the affected episode (or - // the show metadata) in-memory using the local watch flag the event - // already carries. The sync service drains queued actions to the - // server when the device reconnects. - if (epIndex != -1 && event.isNowWatched != null) { - setStateIfMounted(() { - final updated = _episodes[epIndex].copyWith( - viewCount: event.isNowWatched! ? 1 : 0, - viewOffsetMs: event.isNowWatched! ? _episodes[epIndex].viewOffsetMs : 0, - ); - _episodes[epIndex] = updated; - _syncEpisodeToCache(epIndex, updated); - }); - } else if (event.itemId == _metadata.id) { - unawaited(_updateWatchStateOffline()); + if (_metadata.isShow) { + unawaited(_loadOfflineOnDeckEpisode()); } return; } - // Online: re-fetch the affected row so server-derived counters - // (parent leafCounts, lastViewedAt) refresh too. - if (epIndex != -1) { - _updateEpisodeWatchState(event.itemId); - } else { - _refreshWatchState(); - } + // Online: refresh server-derived counters and on-deck state. A watched + // episode can change the hero play target even when the episode row itself + // was already visible and patched locally. + unawaited(_refreshWatchState()); + } + + void _patchWatchedStateFromEvent(WatchStateEvent event, {required int epIndex, required bool clearWatchedProgress}) { + final isWatched = event.isNowWatched; + if (isWatched == null) return; + final viewOffsetMs = isWatched && !clearWatchedProgress ? null : 0; + setStateIfMounted(() { + final base = _fullMetadata ?? widget.metadata; + if (base.id == event.itemId) { + _fullMetadata = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs); + } + + final onDeckEpisode = _onDeckEpisode; + if (onDeckEpisode != null && onDeckEpisode.id == event.itemId) { + _onDeckEpisode = onDeckEpisode.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs); + } + + if (epIndex != -1) { + final updated = _episodes[epIndex].copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs); + _episodes[epIndex] = updated; + _syncEpisodeToCache(epIndex, updated); + } + }); } void _patchLocalProgress(String itemId, int viewOffset, {int? epIndex}) { @@ -377,9 +396,9 @@ class _MediaDetailScreenState extends State if (metadata != null) { setStateIfMounted(() { _fullMetadata = _applyLocalProgress(metadata.copyWith(serverId: serverId, serverName: serverName)); - if (onDeckEpisode != null) { - _onDeckEpisode = _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName)); - } + _onDeckEpisode = onDeckEpisode == null + ? null + : _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName)); }); } @@ -403,30 +422,6 @@ class _MediaDetailScreenState extends State } } - /// Update a single episode's watch state without refetching everything. - /// Backend-neutral so Jellyfin items refresh in place when their - /// watched flag changes (the previous Plex-only path no-op'd for - /// Jellyfin and left the row stale). - Future _updateEpisodeWatchState(String ratingKey) async { - final mediaClient = _getMediaClientForMetadata(context); - if (mediaClient == null) return; - try { - final refreshed = await mediaClient.fetchItem(ratingKey); - if (refreshed != null) { - setStateIfMounted(() { - final i = _episodes.indexWhere((e) => e.id == ratingKey); - if (i != -1) { - final updated = _applyLocalProgress(refreshed); - _episodes[i] = updated; - _syncEpisodeToCache(i, updated); - } - }); - } - } catch (e) { - appLogger.d('Episode cache sync skipped', error: e); - } - } - @override void initState() { super.initState(); @@ -2009,34 +2004,15 @@ class _MediaDetailScreenState extends State final offlineWatchProvider = context.read(); final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id); + setStateIfMounted(() { + _onDeckEpisode = nextEpisode == null ? null : _applyLocalProgress(nextEpisode); + }); + if (nextEpisode != null) { - setStateIfMounted(() { - _onDeckEpisode = _applyLocalProgress(nextEpisode); - }); appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}'); } } - /// Offline: patch the in-memory metadata so the UI reflects a queued - /// watch/unwatch action immediately. The sync service holds the truth - /// for offline state and will reconcile with the server on reconnect, - /// so we don't need to round-trip through the per-backend cache here. - Future _updateWatchStateOffline() async { - final serverId = _metadata.serverId; - if (serverId == null) return; - final localStatus = await context.read().getLocalWatchStatus('$serverId:${_metadata.id}'); - if (localStatus == null) return; - setStateIfMounted(() { - final base = _fullMetadata ?? _metadata; - _fullMetadata = base.copyWith( - viewCount: localStatus ? 1 : 0, - // Reset the resume position when transitioning to unwatched, mirroring - // the previous Plex cache-mutation behavior. - viewOffsetMs: localStatus ? base.viewOffsetMs : 0, - ); - }); - } - Future _playFirstEpisode() async { try { // If seasons aren't loaded yet, wait for them or load them diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index e53bef53..082d8753 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -43,6 +43,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen widget.playlist; + @override + String? get itemServerId => widget.playlist.serverId; + @override String get title => widget.playlist.title; diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 188907ac..0f3a3507 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -1,9 +1,11 @@ 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 '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../mixins/context_menu_tap_mixin.dart'; +import '../../providers/watch_state_overlay_provider.dart'; import '../../utils/formatters.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; @@ -44,8 +46,20 @@ 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), + ); + return WatchStateOverlayProvider.applyPatch(widget.item, patch); + } on ProviderNotFoundException { + return widget.item; + } + } + @override Widget build(BuildContext context) { + final item = _effectiveItem(context); final colorScheme = Theme.of(context).colorScheme; // Determine if row is focused (main content area) @@ -71,7 +85,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap return MediaContextMenu( key: contextMenuKey, - item: widget.item, + item: item, onRefresh: widget.onRefresh, onTap: widget.onTap, child: Card( @@ -118,7 +132,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap ), // Poster thumbnail - _buildPosterImage(context), + _buildPosterImage(context, item), const SizedBox(width: 12), @@ -130,7 +144,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap children: [ // Title Text( - widget.item.displayTitle, + item.displayTitle, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -140,19 +154,19 @@ class _PlaylistItemCardState extends State with ContextMenuTap // Subtitle (episode info or type) Text( - _buildSubtitle(), + _buildSubtitle(item), style: TextStyle(fontSize: 13, color: Colors.grey[400]), maxLines: 1, overflow: TextOverflow.ellipsis, ), // Progress indicator if partially watched - if (widget.item.viewOffsetMs != null && widget.item.durationMs != null) + if (item.viewOffsetMs != null && item.durationMs != null) Padding( padding: const EdgeInsets.only(top: 6), child: MediaProgressBar( - viewOffset: widget.item.viewOffsetMs!, - duration: widget.item.durationMs!, + viewOffset: item.viewOffsetMs!, + duration: item.durationMs!, minHeight: 3, ), ), @@ -163,9 +177,9 @@ class _PlaylistItemCardState extends State with ContextMenuTap const SizedBox(width: 12), // Duration - if (widget.item.durationMs != null) + if (item.durationMs != null) Text( - formatDurationTextual(widget.item.durationMs!), + formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), @@ -194,14 +208,14 @@ class _PlaylistItemCardState extends State with ContextMenuTap ); } - Widget _buildPosterImage(BuildContext context) { - final posterUrl = widget.item.posterThumb(); + Widget _buildPosterImage(BuildContext context, MediaItem item) { + final posterUrl = item.posterThumb(); return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(6)), child: OptimizedMediaImage.poster( // Backend-neutral lookup so Jellyfin items render via their own // image transcoder; null falls through to the placeholder below. - client: context.tryGetMediaClientWithFallback(widget.item.serverId), + client: context.tryGetMediaClientWithFallback(item.serverId), imagePath: posterUrl, width: 60, height: 90, @@ -221,21 +235,21 @@ class _PlaylistItemCardState extends State with ContextMenuTap ); } - String _buildSubtitle() { - final kind = widget.item.kind; + String _buildSubtitle(MediaItem item) { + final kind = item.kind; if (kind == MediaKind.episode) { // For episodes, show "S#E# - Episode Title" - final season = widget.item.parentIndex; - final episode = widget.item.index; + final season = item.parentIndex; + final episode = item.index; if (season != null && episode != null) { - return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}'; + return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}'; } - return widget.item.displaySubtitle ?? t.discover.tvShow; + return item.displaySubtitle ?? t.discover.tvShow; } else if (kind == MediaKind.movie) { // For movies, show year and edition (edition is Plex-only; null elsewhere) - final year = widget.item.year?.toString(); - final edition = widget.item.editionTitle; + final year = item.year?.toString(); + final edition = item.editionTitle; if (year != null && edition != null) { return '$year · $edition'; } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index d8fb7b46..63f15b73 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -180,6 +180,7 @@ class _SearchScreenState extends State forceListMode: true, disableScale: true, focusNode: index == 0 ? _firstResultFocusNode : null, + onRefresh: updateItem, onListRefresh: () => updateItem(item.id), onNavigateLeft: _navigateToSidebar, onNavigateUp: index == 0 ? focusSearchInput : null, diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index dde0ccd4..5bef83ef 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -69,6 +69,10 @@ class PlaybackProgressTracker { /// Whether the final stopped progress event was already emitted locally. bool _stopProgressNotified = false; + Duration? _lastProgressNotifiedPosition; + + static const Duration _progressNotifyDelta = Duration(seconds: 30); + final PlaybackReportSession? _reportSession; PlaybackProgressTracker({ @@ -165,16 +169,23 @@ class PlaybackProgressTracker { if (isOffline) { // Queue progress update for later sync await _sendOfflineProgress(position, duration); + _notifyProgressIfNeeded(position, duration, force: state == 'stopped'); } else if (state == 'stopped') { // Stopped must complete before disposal - await _sendOnlineProgress(state, position, duration); + final accepted = await _sendOnlineProgress(state, position, duration); _resetBackoff(); + if (accepted) { + _notifyProgressIfNeeded(position, duration, force: true); + } } else { // Fire-and-forget for playing/paused — avoid blocking the Dart event loop unawaited( _sendOnlineProgress(state, position, duration) - .then((_) { + .then((accepted) { _resetBackoff(); + if (accepted) { + _notifyProgressIfNeeded(position, duration); + } }) .catchError((Object e) { _consecutiveFailures++; @@ -188,18 +199,6 @@ class PlaybackProgressTracker { }), ); } - - // Emit watch state event on stop for UI updates across screens. - // Skip if already scrobbled — markWatched already emitted a watched event. - if (state == 'stopped' && position.inMilliseconds > 0 && !_scrobbled && !_stopProgressNotified) { - _stopProgressNotified = true; - WatchStateNotifier().notifyProgress( - item: metadata, - viewOffset: position.inMilliseconds, - duration: duration.inMilliseconds, - watchedThreshold: client?.watchedThreshold ?? 0.9, - ); - } } catch (e) { if (!isOffline) { _consecutiveFailures++; @@ -222,12 +221,32 @@ class PlaybackProgressTracker { } } + void _notifyProgressIfNeeded(Duration position, Duration duration, {bool force = false}) { + if (_scrobbled) return; + if (position.inMilliseconds <= 0 || duration.inMilliseconds <= 0) return; + if (force) { + if (_stopProgressNotified) return; + _stopProgressNotified = true; + } else { + final last = _lastProgressNotifiedPosition; + if (last != null && (position - last).abs() < _progressNotifyDelta) return; + } + + _lastProgressNotifiedPosition = position; + WatchStateNotifier().notifyProgress( + item: metadata, + viewOffset: position.inMilliseconds, + duration: duration.inMilliseconds, + watchedThreshold: client?.watchedThreshold ?? 0.9, + ); + } + /// Send progress update to the active server through the unified /// [MediaServerClient.reportPlayback*] surface. - Future _sendOnlineProgress(String state, Duration position, Duration duration) async { + Future _sendOnlineProgress(String state, Duration position, Duration duration) async { final c = client; final session = _reportSession; - if (c == null || session == null) return; + if (c == null || session == null) return false; final accepted = await session.report( PlaybackReportSnapshot( @@ -243,6 +262,7 @@ class PlaybackProgressTracker { if (accepted) { await _maybeScrobble(c, position, duration); } + return accepted; } PlaybackStreamSelection _currentStreamSelectionForStopped() { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8d36bffe..ca935b9f 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3068,7 +3068,10 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements Future markUnwatched(MediaItem item) => markAsUnwatched(item.id, item: item); @override - Future removeFromContinueWatching(MediaItem item) => removeFromOnDeck(item.id); + Future removeFromContinueWatching(MediaItem item) async { + await removeFromOnDeck(item.id); + WatchStateNotifier().notifyRemovedFromContinueWatching(item: item); + } /// Rate a media item (0.0-10.0 scale, where each integer = half a star). /// Pass `-1` to clear an existing rating. Throws [MediaServerHttpException] diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index d30ba550..4fa319ec 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -109,7 +109,7 @@ class TraktSyncService { Future _onWatchStateEvent(WatchStateEvent event) async { if (!_canPush) return; - if (event.changeType == WatchStateChangeType.progressUpdate) return; + if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) return; final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType); if (kind == null) return; diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 91ad2d4e..9d59d77a 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -4,7 +4,7 @@ import 'base_notifier.dart'; import 'global_key_utils.dart'; import 'hierarchical_event_mixin.dart'; -enum WatchStateChangeType { watched, unwatched, progressUpdate } +enum WatchStateChangeType { watched, unwatched, progressUpdate, removedFromContinueWatching } /// Event representing a watch state change with parent chain for hierarchical invalidation class WatchStateEvent with HierarchicalEventMixin { @@ -132,4 +132,18 @@ class WatchStateNotifier extends BaseNotifier { ), ); } + + /// Helper to emit a Continue Watching removal event. + void notifyRemovedFromContinueWatching({required MediaItem item}) { + notify( + WatchStateEvent( + itemId: item.id, + serverId: item.serverId ?? '', + changeType: WatchStateChangeType.removedFromContinueWatching, + parentChain: item.parentChain, + mediaType: item.kind.id, + librarySectionID: item.libraryId, + ), + ); + } } diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index d36fa7b3..75ea67a1 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -8,6 +8,7 @@ import '../focus/focusable_wrapper.dart'; import '../mixins/context_menu_tap_mixin.dart'; import '../models/download_models.dart'; import '../providers/download_provider.dart'; +import '../providers/watch_state_overlay_provider.dart'; import 'package:provider/provider.dart'; import '../services/settings_service.dart'; @@ -56,7 +57,18 @@ class EpisodeCard extends StatefulWidget { } class _EpisodeCardState extends State with ContextMenuTapMixin { - Widget _buildEpisodeMetaRow(BuildContext context) { + MediaItem _effectiveEpisode(BuildContext context) { + try { + final patch = context.select( + (provider) => provider.patchForGlobalKey(widget.episode.globalKey), + ); + return WatchStateOverlayProvider.applyPatch(widget.episode, patch); + } on ProviderNotFoundException { + return widget.episode; + } + } + + Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode) { final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12); final dot = Padding( padding: const EdgeInsets.symmetric(horizontal: 6), @@ -64,13 +76,13 @@ class _EpisodeCardState extends State with ContextMenuTapMixin 0) ...[ + if (episode.userRating != null && episode.userRating! > 0) ...[ dot, const Padding( padding: EdgeInsets.only(top: 2), @@ -78,9 +90,9 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin 0; - final progress = hasProgress ? widget.episode.viewOffsetMs! / widget.episode.durationMs! : 0.0; + !widget.isOffline && episode.viewOffsetMs != null && episode.durationMs != null && episode.viewOffsetMs! > 0; + final progress = hasProgress ? episode.viewOffsetMs! / episode.durationMs! : 0.0; - final hasActiveProgress = hasProgress && widget.episode.viewOffsetMs! < widget.episode.durationMs!; + final hasActiveProgress = hasProgress && episode.viewOffsetMs! < episode.durationMs!; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), @@ -121,12 +131,12 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin( - selector: (_, p) => _DownloadSlice.from( - p.getProgress(widget.episode.globalKey), - p.isQueueing(widget.episode.globalKey), - ), + selector: (_, p) => + _DownloadSlice.from(p.getProgress(episode.globalKey), p.isQueueing(episode.globalKey)), builder: (context, slice, _) { Widget? downloadStatusIcon; // Only show download status in online mode - if (!widget.isOffline && widget.episode.serverId != null) { + if (!widget.isOffline && episode.serverId != null) { final status = slice.status; final mutedBase = tokens(context).textMuted; @@ -263,7 +271,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin const PlaceholderContainer(), diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 03b8361d..bf74897e 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -11,6 +11,7 @@ import '../media/media_kind.dart'; import '../media/media_playlist.dart'; import '../mixins/context_menu_tap_mixin.dart'; import '../providers/download_provider.dart'; +import '../providers/watch_state_overlay_provider.dart'; import '../services/download_storage_service.dart'; import '../services/settings_service.dart'; import 'settings_builder.dart'; @@ -82,12 +83,33 @@ class MediaCard extends StatefulWidget { class MediaCardState extends State with ContextMenuTapMixin { /// Public method to trigger tap action (for keyboard/gamepad SELECT) void handleTap() { - _handleTap(context); + _handleTap(context, _effectiveItemForAction(context)); } - String _buildSemanticLabel() { + Object _effectiveItem(BuildContext context) { final item = widget.item; + if (item is! MediaItem) return item; + try { + final patch = context.select( + (provider) => provider.patchForGlobalKey(item.globalKey), + ); + return WatchStateOverlayProvider.applyPatch(item, patch); + } on ProviderNotFoundException { + return 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; + } + } + + String _buildSemanticLabel(Object item) { // Playlists don't expose kind, so build a simple localized label and exit early if (item is MediaPlaylist) { final count = item.leafCount; @@ -132,7 +154,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin(); @@ -192,6 +213,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin _handleTap(context), + onTap: () => _handleTap(context, item), onTapDown: storeTapPosition, onLongPress: showContextMenuFromTap, onSecondaryTapDown: storeTapPosition, @@ -224,11 +246,11 @@ class MediaCardState extends State with ContextMenuTapMixin _handleTap(context), + onTap: () => _handleTap(context, item), isInContinueWatching: widget.isInContinueWatching, collectionId: widget.collectionId, child: cardWidget, @@ -237,8 +259,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin _handleTap(context), + onTap: () => _handleTap(context, item), onTapDown: storeTapPosition, onLongPress: showContextMenuFromTap, onSecondaryTapDown: storeTapPosition, diff --git a/test/providers/watch_state_overlay_provider_test.dart b/test/providers/watch_state_overlay_provider_test.dart new file mode 100644 index 00000000..02eb818c --- /dev/null +++ b/test/providers/watch_state_overlay_provider_test.dart @@ -0,0 +1,65 @@ +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/utils/watch_state_notifier.dart'; + +MediaItem _item({String id = '1', int? viewOffsetMs, int? viewCount = 0}) { + return MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'server', + durationMs: 100000, + viewOffsetMs: viewOffsetMs, + viewCount: viewCount, + ); +} + +Future _drainEvents() => Future.delayed(Duration.zero); + +void main() { + group('WatchStateOverlayProvider', () { + test('applies watched patches immediately', () async { + final provider = WatchStateOverlayProvider(); + addTearDown(provider.dispose); + final item = _item(viewOffsetMs: 40000); + + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); + await _drainEvents(); + + final patched = provider.apply(item); + expect(patched.isWatched, isTrue); + expect(patched.viewOffsetMs, 0); + }); + + test('applies progress patches without changing watched state', () async { + final provider = WatchStateOverlayProvider(); + addTearDown(provider.dispose); + final item = _item(viewCount: 1); + + WatchStateNotifier().notifyProgress(item: item, viewOffset: 30000, duration: 100000); + await _drainEvents(); + + final patched = provider.apply(item); + expect(patched.isWatched, isTrue); + expect(patched.viewOffsetMs, 30000); + }); + + test('clears patches when active profile changes', () async { + final provider = WatchStateOverlayProvider(); + addTearDown(provider.dispose); + final item = _item(); + + provider.setActiveProfileId('a'); + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); + await _drainEvents(); + expect(provider.apply(item).isWatched, isTrue); + + provider.setActiveProfileId('b'); + expect(provider.apply(item).isWatched, isFalse); + }); + }); +}