diff --git a/lib/main.dart b/lib/main.dart index 79d7ae8a..78f2125b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,7 +51,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/watch_state_store.dart'; import 'providers/companion_remote_provider.dart'; import 'providers/shader_provider.dart'; import 'utils/snackbar_helper.dart'; @@ -775,10 +775,10 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, ), - ChangeNotifierProxyProvider2( - create: (_) => WatchStateOverlayProvider(), + ChangeNotifierProxyProvider2( + create: (_) => WatchStateStore(), update: (_, activeProfile, multiServer, previous) { - final provider = previous ?? WatchStateOverlayProvider(); + final provider = previous ?? WatchStateStore(); provider.setActiveProfileId(activeProfile.activeId); provider.setActiveClientScopesByServer({ for (final serverId in multiServer.serverManager.serverIds) diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index b280d0a2..5f03426a 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -389,6 +389,16 @@ sealed class MediaItem with _$MediaItem { return viewCount != null && viewCount! > 0; } + /// Copy with the watched flag applied so [isWatched] reflects it for every + /// kind: containers need their leaf counts patched, not just [viewCount]. + MediaItem withWatchedFlag(bool isWatched) { + var updated = copyWith(viewCount: isWatched ? 1 : 0); + if (leafCount != null || viewedLeafCount != null) { + updated = updated.copyWith(viewedLeafCount: isWatched ? (leafCount ?? viewedLeafCount ?? 1) : 0); + } + return updated; + } + /// Display-friendly title that prefers the show name for episodes/seasons. String get displayTitle { if ((kind == MediaKind.episode || kind == MediaKind.season) && grandparentTitle != null) { diff --git a/lib/providers/watch_state_overlay_provider.dart b/lib/providers/watch_state_store.dart similarity index 57% rename from lib/providers/watch_state_overlay_provider.dart rename to lib/providers/watch_state_store.dart index 119c105f..34748af1 100644 --- a/lib/providers/watch_state_overlay_provider.dart +++ b/lib/providers/watch_state_store.dart @@ -10,14 +10,14 @@ import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; @immutable -class WatchStateOverlayPatch { +class WatchStatePatch { final bool? isWatched; final bool hasViewOffsetMs; final int? viewOffsetMs; - const WatchStateOverlayPatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs}); + const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs}); - factory WatchStateOverlayPatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStateOverlayPatch( + factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch( isWatched: snapshot.isWatched, hasViewOffsetMs: snapshot.hasViewOffsetMs, viewOffsetMs: snapshot.viewOffsetMs, @@ -26,7 +26,7 @@ class WatchStateOverlayPatch { @override bool operator ==(Object other) => identical(this, other) || - other is WatchStateOverlayPatch && + other is WatchStatePatch && other.isWatched == isWatched && other.hasViewOffsetMs == hasViewOffsetMs && other.viewOffsetMs == viewOffsetMs; @@ -35,30 +35,35 @@ class WatchStateOverlayPatch { int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); } -class _WatchStateOverlayEntry { - final WatchStateOverlayPatch patch; +class _WatchStatePatchEntry { + final WatchStatePatch patch; final int sequence; - const _WatchStateOverlayEntry(this.patch, this.sequence); + const _WatchStatePatchEntry(this.patch, this.sequence); } -/// Session-local watch-state overlay for immediate UI freshness. +/// The single session-local layer for watch-state 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() { +/// Server fetches remain the source of truth; [MediaItem] snapshots are never +/// hand-mutated to reflect watch events. Instead, every watch event lands here +/// as a patch, and consumers resolve items at point of use ([apply] / +/// [patchForItem]). Resolution is hierarchy-aware: an item's effective patch +/// is the newest among its own and its [MediaItem.parentChain] ancestors', so +/// marking a show/season reaches every descendant, while a later per-item +/// event still overrides an older container mark. +class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin { + WatchStateStore() { _subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent); } StreamSubscription? _subscription; - final Map _patches = {}; + final Map _patches = {}; String? _activeProfileId; Map _activeClientScopesByServer = const {}; int _sequence = 0; - WatchStateOverlayPatch? patchForGlobalKey(String globalKey) { - _WatchStateOverlayEntry? scopedEntry; + _WatchStatePatchEntry? _entryFor(String globalKey) { + _WatchStatePatchEntry? scopedEntry; final parsed = parseGlobalKey(globalKey); if (parsed != null) { final scoped = _activeClientScopesByServer[parsed.serverId]; @@ -67,18 +72,36 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti } } final unscopedEntry = _patches[globalKey]; - if (scopedEntry == null) return unscopedEntry?.patch; - if (unscopedEntry == null) return scopedEntry.patch; - return scopedEntry.sequence >= unscopedEntry.sequence ? scopedEntry.patch : unscopedEntry.patch; + if (scopedEntry == null) return unscopedEntry; + if (unscopedEntry == null) return scopedEntry; + return scopedEntry.sequence >= unscopedEntry.sequence ? scopedEntry : unscopedEntry; } - WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey); + WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; + + WatchStatePatch? patchForItem(MediaItem item) { + var best = _entryFor(item.globalKey); + if (item.parentChain.isNotEmpty) { + final serverId = serverIdOrNull(item.serverId); + for (final parentId in item.parentChain) { + // Mirror MediaItem.globalKey's bare-id fallback when serverId is missing. + final entry = _entryFor(serverId != null ? buildGlobalKey(serverId, parentId) : parentId); + if (entry != null && (best == null || entry.sequence > best.sequence)) best = entry; + } + } + return best?.patch; + } MediaItem apply(MediaItem item) { return applyPatch(item, patchForItem(item)); } - static MediaItem applyPatch(MediaItem item, WatchStateOverlayPatch? patch) { + List applyAll(List items) { + if (_patches.isEmpty) return items; + return [for (final item in items) apply(item)]; + } + + static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) { if (patch == null) return item; return WatchStateSnapshot( isWatched: patch.isWatched, @@ -108,13 +131,13 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti void _onWatchStateEvent(WatchStateEvent event) { final snapshot = WatchStateResolver.fromEvent(event); if (snapshot.isEmpty) return; - final patch = WatchStateOverlayPatch.fromSnapshot(snapshot); + final patch = WatchStatePatch.fromSnapshot(snapshot); final cacheServerId = event.cacheServerId; final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId ? buildGlobalKey(ServerId(cacheServerId), event.itemId) : event.globalKey; - _patches[key] = _WatchStateOverlayEntry(patch, ++_sequence); + _patches[key] = _WatchStatePatchEntry(patch, ++_sequence); safeNotifyListeners(); } diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 6ace7931..213a1a52 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -6,7 +6,7 @@ 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 '../../providers/watch_state_store.dart'; import '../../utils/formatters.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; @@ -49,10 +49,10 @@ class PlaylistItemCard extends StatefulWidget { class _PlaylistItemCardState extends State with ContextMenuTapMixin { MediaItem _effectiveItem(BuildContext context) { try { - final patch = context.select( + final patch = context.select( (provider) => provider.patchForGlobalKey(widget.item.globalKey), ); - return WatchStateOverlayProvider.applyPatch(widget.item, patch); + return WatchStateStore.applyPatch(widget.item, patch); } on ProviderNotFoundException { return widget.item; } diff --git a/lib/services/watch_state_resolver.dart b/lib/services/watch_state_resolver.dart index 57384ea1..f2949d92 100644 --- a/lib/services/watch_state_resolver.dart +++ b/lib/services/watch_state_resolver.dart @@ -14,7 +14,7 @@ class WatchStateSnapshot { MediaItem apply(MediaItem item) { var updated = item; if (isWatched != null) { - updated = updated.copyWith(viewCount: isWatched! ? 1 : 0); + updated = updated.withWatchedFlag(isWatched!); } if (hasViewOffsetMs) { updated = updated.copyWith(viewOffsetMs: viewOffsetMs); diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 2bfba82f..a824b667 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -8,7 +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 '../providers/watch_state_store.dart'; import 'package:provider/provider.dart'; import '../services/settings_service.dart'; @@ -60,10 +60,10 @@ class EpisodeCard extends StatefulWidget { class _EpisodeCardState extends State with ContextMenuTapMixin { MediaItem _effectiveEpisode(BuildContext context) { try { - final patch = context.select( + final patch = context.select( (provider) => provider.patchForGlobalKey(widget.episode.globalKey), ); - return WatchStateOverlayProvider.applyPatch(widget.episode, patch); + 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 35707d22..4062480c 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -13,7 +13,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 '../providers/watch_state_store.dart'; import '../services/download_storage_service.dart'; import '../services/settings_service.dart'; import 'settings_builder.dart'; @@ -97,10 +97,10 @@ class MediaCardState extends State with ContextMenuTapMixin( + final patch = context.select( (provider) => provider.patchForGlobalKey(item.globalKey), ); - return WatchStateOverlayProvider.applyPatch(item, patch); + return WatchStateStore.applyPatch(item, patch); } on ProviderNotFoundException { return item; } @@ -110,7 +110,7 @@ class MediaCardState extends State with ContextMenuTapMixin().apply(item); + return context.read().apply(item); } on ProviderNotFoundException { return item; } diff --git a/test/providers/watch_state_overlay_provider_test.dart b/test/providers/watch_state_overlay_provider_test.dart deleted file mode 100644 index a698724c..00000000 --- a/test/providers/watch_state_overlay_provider_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/media/ids.dart'; -import 'package:plezy/providers/watch_state_overlay_provider.dart'; -import 'package:plezy/utils/watch_state_notifier.dart'; - -Future _emit(WatchStateEvent event) async { - WatchStateNotifier().notify(event); - await Future.delayed(Duration.zero); -} - -WatchStateEvent _event({ - required WatchStateChangeType changeType, - required bool? isNowWatched, - String serverId = 'jf-machine', - String itemId = 'item-1', - String? cacheServerId, - int? viewOffset, -}) { - return WatchStateEvent( - itemId: itemId, - serverId: ServerId(serverId), - cacheServerId: cacheServerId, - changeType: changeType, - parentChain: const [], - mediaType: 'movie', - isNowWatched: isNowWatched, - viewOffset: viewOffset, - ); -} - -void main() { - test('removed from continue watching does not replace an existing watched patch', () async { - final provider = WatchStateOverlayProvider(); - addTearDown(provider.dispose); - - await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true)); - await _emit(_event(changeType: WatchStateChangeType.removedFromContinueWatching, isNowWatched: null)); - - final patch = provider.patchForGlobalKey('jf-machine:item-1'); - expect(patch?.isWatched, isTrue); - expect(patch?.viewOffsetMs, 0); - }); - - test('newer unscoped patch wins over older active scoped patch', () async { - final provider = WatchStateOverlayProvider(); - addTearDown(provider.dispose); - provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); - - await _emit( - _event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'), - ); - await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false)); - - expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isFalse); - }); - - test('newer active scoped patch wins over older unscoped patch', () async { - final provider = WatchStateOverlayProvider(); - addTearDown(provider.dispose); - provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); - - await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false)); - await _emit( - _event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'), - ); - - expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue); - }); -} diff --git a/test/providers/watch_state_store_test.dart b/test/providers/watch_state_store_test.dart new file mode 100644 index 00000000..7ecf6144 --- /dev/null +++ b/test/providers/watch_state_store_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.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_store.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; + +Future _emit(WatchStateEvent event) async { + WatchStateNotifier().notify(event); + await Future.delayed(Duration.zero); +} + +WatchStateEvent _event({ + required WatchStateChangeType changeType, + required bool? isNowWatched, + String serverId = 'jf-machine', + String itemId = 'item-1', + String? cacheServerId, + int? viewOffset, + List parentChain = const [], + String mediaType = 'movie', +}) { + return WatchStateEvent( + itemId: itemId, + serverId: ServerId(serverId), + cacheServerId: cacheServerId, + changeType: changeType, + parentChain: parentChain, + mediaType: mediaType, + isNowWatched: isNowWatched, + viewOffset: viewOffset, + ); +} + +final _episode = MediaItem( + id: 'episode-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + parentId: 'season-1', + grandparentId: 'show-1', + serverId: 'jf-machine', +); + +void main() { + test('removed from continue watching does not replace an existing watched patch', () async { + final provider = WatchStateStore(); + addTearDown(provider.dispose); + + await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true)); + await _emit(_event(changeType: WatchStateChangeType.removedFromContinueWatching, isNowWatched: null)); + + final patch = provider.patchForGlobalKey('jf-machine:item-1'); + expect(patch?.isWatched, isTrue); + expect(patch?.viewOffsetMs, 0); + }); + + test('newer unscoped patch wins over older active scoped patch', () async { + final provider = WatchStateStore(); + addTearDown(provider.dispose); + provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); + + await _emit( + _event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'), + ); + await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false)); + + expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isFalse); + }); + + test('newer active scoped patch wins over older unscoped patch', () async { + final provider = WatchStateStore(); + addTearDown(provider.dispose); + provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); + + await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false)); + await _emit( + _event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'), + ); + + expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue); + }); + + test('an ancestor patch reaches descendants through parentChain', () async { + final store = WatchStateStore(); + addTearDown(store.dispose); + + await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'show-1')); + + expect(store.patchForItem(_episode)?.isWatched, isTrue); + expect(store.apply(_episode).isWatched, isTrue); + // The episode's own key still has no patch — only resolution sees the ancestor. + expect(store.patchForGlobalKey(_episode.globalKey), isNull); + }); + + test('newer container mark overrides an older per-item patch', () async { + final store = WatchStateStore(); + addTearDown(store.dispose); + + await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false, itemId: 'episode-1')); + await _emit( + _event( + changeType: WatchStateChangeType.watched, + isNowWatched: true, + itemId: 'season-1', + parentChain: ['show-1'], + mediaType: 'season', + ), + ); + + expect(store.patchForItem(_episode)?.isWatched, isTrue); + }); + + test('newer per-item patch overrides an older container mark', () async { + final store = WatchStateStore(); + addTearDown(store.dispose); + + await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'show-1')); + await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false, itemId: 'episode-1')); + + expect(store.patchForItem(_episode)?.isWatched, isFalse); + }); + + test('ancestor patches resolve through the active client scope', () async { + final store = WatchStateStore(); + addTearDown(store.dispose); + store.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); + + await _emit( + _event( + changeType: WatchStateChangeType.watched, + isNowWatched: true, + itemId: 'show-1', + cacheServerId: 'jf-machine/user-a', + ), + ); + + expect(store.patchForItem(_episode)?.isWatched, isTrue); + }); + + test('applying a watched patch to a container also patches leaf counts', () async { + final store = WatchStateStore(); + addTearDown(store.dispose); + + await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'season-1')); + + final season = MediaItem( + id: 'season-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + parentId: 'show-1', + serverId: 'jf-machine', + leafCount: 10, + viewedLeafCount: 3, + ); + final resolved = store.apply(season); + expect(resolved.viewedLeafCount, 10); + expect(resolved.isWatched, isTrue); + }); +} diff --git a/test/services/watch_state_resolver_test.dart b/test/services/watch_state_resolver_test.dart index c0eb0b18..e288d9ba 100644 --- a/test/services/watch_state_resolver_test.dart +++ b/test/services/watch_state_resolver_test.dart @@ -1,6 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/database/app_database.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/services/watch_state_resolver.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; @@ -80,4 +83,24 @@ void main() { expect(snapshot.isEmpty, isTrue); }); + + test('applying a watched snapshot patches container leaf counts so isWatched flips', () { + const snapshot = WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0); + final season = MediaItem( + id: 'season-1', + backend: MediaBackend.plex, + kind: MediaKind.season, + leafCount: 8, + viewedLeafCount: 2, + serverId: 'srv', + ); + + final resolved = snapshot.apply(season); + expect(resolved.viewedLeafCount, 8); + expect(resolved.isWatched, isTrue); + + final unmarked = const WatchStateSnapshot(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0).apply(resolved); + expect(unmarked.viewedLeafCount, 0); + expect(unmarked.isWatched, isFalse); + }); }