From bbee74ef9dd64f298efc23bd1dded79d8a718704 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:58:57 +0100 Subject: [PATCH] fix(propagation): scope events by server --- lib/mixins/deletion_aware.dart | 26 +++++++++++++-- lib/mixins/watch_state_aware.dart | 22 +++++++++++++ lib/screens/discover_screen.dart | 20 +++++++++++ .../libraries/tabs/library_browse_tab.dart | 19 +++++++++++ lib/screens/media_detail_screen.dart | 33 +++++++++++++++++++ lib/screens/season_detail_screen.dart | 26 +++++++++++++++ lib/utils/deletion_notifier.dart | 3 ++ lib/utils/watch_state_notifier.dart | 3 ++ lib/widgets/media_context_menu.dart | 2 ++ 9 files changed, 152 insertions(+), 2 deletions(-) diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart index f5201303..542eafc1 100644 --- a/lib/mixins/deletion_aware.dart +++ b/lib/mixins/deletion_aware.dart @@ -27,6 +27,17 @@ import '../utils/deletion_notifier.dart'; mixin DeletionAware on State { StreamSubscription? _deletionSubscription; + /// Override to scope events to a specific server. + /// + /// Return null to receive events from all servers. + String? get deletionServerId => null; + + /// Override to specify which global keys this screen cares about. + /// + /// Use format `serverId:ratingKey`. + /// Return null to fall back to [deletionRatingKeys] matching. + Set? get deletionGlobalKeys => null; + /// Override to specify which ratingKeys this screen cares about. /// /// Return null to receive ALL events (not recommended for performance). @@ -52,10 +63,21 @@ mixin DeletionAware on State { _deletionSubscription = DeletionNotifier().stream.listen((event) { if (!mounted) return; - final keys = deletionRatingKeys; + final serverId = deletionServerId; + if (serverId != null && event.serverId != serverId) return; + + final globalKeys = deletionGlobalKeys; + if (globalKeys != null) { + if (event.affectsAnyGlobalKey(globalKeys)) { + onDeletionEvent(event); + } + return; + } + + final ratingKeys = deletionRatingKeys; // If keys is null, receive all events // Otherwise, filter to events that affect our keys - if (keys == null || event.affectsAnyOf(keys)) { + if (ratingKeys == null || event.affectsAnyOf(ratingKeys)) { onDeletionEvent(event); } }); diff --git a/lib/mixins/watch_state_aware.dart b/lib/mixins/watch_state_aware.dart index 1da5f006..a0efe029 100644 --- a/lib/mixins/watch_state_aware.dart +++ b/lib/mixins/watch_state_aware.dart @@ -26,6 +26,17 @@ import '../utils/watch_state_notifier.dart'; mixin WatchStateAware on State { StreamSubscription? _watchStateSubscription; + /// Override to scope events to a specific server. + /// + /// Return null to receive events from all servers. + String? get watchStateServerId => null; + + /// Override to specify which global keys this screen cares about. + /// + /// Use format `serverId:ratingKey`. + /// Return null to fall back to [watchedRatingKeys] matching. + Set? get watchedGlobalKeys => null; + /// Override to specify which ratingKeys this screen cares about. /// /// Return null to receive ALL events (not recommended for performance). @@ -51,6 +62,17 @@ mixin WatchStateAware on State { _watchStateSubscription = WatchStateNotifier().stream.listen((event) { if (!mounted) return; + final serverId = watchStateServerId; + if (serverId != null && event.serverId != serverId) return; + + final globalKeys = watchedGlobalKeys; + if (globalKeys != null) { + if (event.affectsAnyGlobalKey(globalKeys)) { + onWatchStateChanged(event); + } + return; + } + final keys = watchedRatingKeys; // If keys is null, receive all events // Otherwise, filter to events that affect our keys diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 9914b341..14c096aa 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -75,6 +75,8 @@ class _DiscoverScreenState extends State final ValueNotifier _indicatorProgress = ValueNotifier(0.0); bool _isAutoScrollPaused = false; + String _toGlobalKey(String ratingKey, String serverId) => '$serverId:$ratingKey'; + // WatchStateAware: watch on-deck items and their parent shows/seasons @override Set? get watchedRatingKeys { @@ -91,6 +93,24 @@ class _DiscoverScreenState extends State return keys; } + @override + Set? get watchedGlobalKeys { + final keys = {}; + for (final item in _onDeck) { + final serverId = item.serverId; + if (serverId == null) return null; + + keys.add(_toGlobalKey(item.ratingKey, serverId)); + if (item.parentRatingKey != null) { + keys.add(_toGlobalKey(item.parentRatingKey!, serverId)); + } + if (item.grandparentRatingKey != null) { + keys.add(_toGlobalKey(item.grandparentRatingKey!, serverId)); + } + } + return keys; + } + @override void onWatchStateChanged(WatchStateEvent event) { // Refresh continue watching when any relevant item changes diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index fa855873..27dfe13b 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -59,9 +59,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState getClientForLibrary(); + String _toGlobalKey(String ratingKey, {String? serverId}) => + '${serverId ?? widget.library.serverId ?? ''}:$ratingKey'; + + @override + String? get deletionServerId => widget.library.serverId; + @override Set? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet(); + @override + Set? get deletionGlobalKeys { + if (items.isEmpty) return {}; + + final keys = {}; + for (final item in items) { + final serverId = item.serverId ?? widget.library.serverId; + if (serverId == null) return null; + keys.add(_toGlobalKey(item.ratingKey, serverId: serverId)); + } + return keys; + } + @override void onDeletionEvent(DeletionEvent event) { // If we have an item that matches the rating key exactly, then remove it from our list diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 3b448b14..8a0eebe3 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -76,6 +76,9 @@ class _MediaDetailScreenState extends State with WatchStateAw // GlobalKeys for season cards to access their context menu final Map> _seasonCardKeys = {}; + String _toGlobalKey(String ratingKey, {String? serverId}) => + '${serverId ?? widget.metadata.serverId ?? ''}:$ratingKey'; + // WatchStateAware: watch the show/movie and all season ratingKeys @override Set? get watchedRatingKeys { @@ -86,6 +89,21 @@ class _MediaDetailScreenState extends State with WatchStateAw return keys; } + @override + String? get watchStateServerId => widget.metadata.serverId; + + @override + Set? get watchedGlobalKeys { + final serverId = widget.metadata.serverId; + if (serverId == null) return null; + + final keys = {_toGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; + for (final season in _seasons) { + keys.add(_toGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + } + return keys; + } + @override void onWatchStateChanged(WatchStateEvent event) { // Lightweight refresh - no loader, preserves scroll position @@ -103,6 +121,21 @@ class _MediaDetailScreenState extends State with WatchStateAw return keys; } + @override + String? get deletionServerId => widget.metadata.serverId; + + @override + Set? get deletionGlobalKeys { + final serverId = widget.metadata.serverId; + if (serverId == null) return null; + + final keys = {_toGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; + for (final season in _seasons) { + keys.add(_toGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + } + return keys; + } + @override void onDeletionEvent(DeletionEvent event) { if (widget.isOffline) return; diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index e6bde91f..d9f1d564 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -55,10 +55,23 @@ class _SeasonDetailScreenState extends State bool _suppressNextBackKeyUp = false; bool _routeSubscribed = false; + String _toGlobalKey(String ratingKey, {String? serverId}) => '${serverId ?? widget.season.serverId ?? ''}:$ratingKey'; + // WatchStateAware: watch all episode ratingKeys @override Set? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet(); + @override + String? get watchStateServerId => widget.season.serverId; + + @override + Set? get watchedGlobalKeys { + final serverId = widget.season.serverId; + if (serverId == null) return null; + + return _episodes.map((e) => _toGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet(); + } + @override void onWatchStateChanged(WatchStateEvent event) { // Update the affected episode @@ -74,6 +87,19 @@ class _SeasonDetailScreenState extends State return keys; } + @override + String? get deletionServerId => widget.season.serverId; + + @override + Set? get deletionGlobalKeys { + final serverId = widget.season.serverId; + if (serverId == null) return null; + + final keys = _episodes.map((e) => _toGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet(); + keys.add(_toGlobalKey(widget.season.ratingKey, serverId: serverId)); + return keys; + } + @override void onDeletionEvent(DeletionEvent event) { // If we have an episode that matches the rating key exactly, then remove it from our list diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index d24ef386..374ec223 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -44,6 +44,9 @@ class DeletionEvent { /// Check if this event affects any item in a collection bool affectsAnyOf(Iterable ratingKeys) => ratingKeys.any(affectsItem); + /// Check if this event affects any item in a global-key collection + bool affectsAnyGlobalKey(Iterable globalKeys) => globalKeys.any(affectsGlobalKey); + @override String toString() => 'DeletionEvent(deleted: $globalKey, type: $mediaType, parents: $parentChain)'; } diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 1325b86d..03310307 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -54,6 +54,9 @@ class WatchStateEvent { /// Check if this event affects any item in a collection bool affectsAnyOf(Iterable ratingKeys) => ratingKeys.any(affectsItem); + /// Check if this event affects any item in a global-key collection + bool affectsAnyGlobalKey(Iterable globalKeys) => globalKeys.any(affectsGlobalKey); + @override String toString() => 'WatchStateEvent($changeType, $globalKey, parents: $parentChain)'; } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 9492389e..61f248ab 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1073,6 +1073,8 @@ class MediaContextMenuState extends State { showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully); // Broadcast deletion event for cross-screen propagation DeletionNotifier().notifyDeleted(metadata: metadata); + // Backward-compatible list refresh for screens that are not DeletionAware yet + widget.onListRefresh?.call(); } else { showErrorSnackBar(context, t.mediaMenu.mediaFailedToDelete); }