diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart new file mode 100644 index 00000000..542eafc1 --- /dev/null +++ b/lib/mixins/deletion_aware.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../utils/deletion_notifier.dart'; + +/// Mixin for screens that need to react to deletion events. +/// +/// Provides automatic subscription management and filtering based on +/// which items the screen cares about. +/// +/// Example usage: +/// ```dart +/// class _MyScreenState extends State with DeletionAware { +/// List _items = []; +/// +/// @override +/// Set? get deletionRatingKeys => +/// _items.map((e) => e.ratingKey).toSet(); +/// +/// @override +/// void onDeletionEvent(DeletionEvent event) { +/// setState(() { +/// _items.removeWhere((e) => e.ratingKey == event.ratingKey); +/// }); +/// } +/// } +/// ``` +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). + /// Return an empty set to receive no events. + /// + /// The set should include: + /// - Direct items displayed (e.g., episode ratingKeys in a season view) + /// - Parent items that affect display (e.g., show ratingKey for seasons) + Set? get deletionRatingKeys; + + /// Called when a relevant deletion event occurs. + /// + /// Only called if [deletionRatingKeys] is null or contains an affected key. + void onDeletionEvent(DeletionEvent event); + + @override + void initState() { + super.initState(); + _subscribeToDeletions(); + } + + void _subscribeToDeletions() { + _deletionSubscription = DeletionNotifier().stream.listen((event) { + if (!mounted) return; + + 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 (ratingKeys == null || event.affectsAnyOf(ratingKeys)) { + onDeletionEvent(event); + } + }); + } + + @override + void dispose() { + _deletionSubscription?.cancel(); + _deletionSubscription = null; + super.dispose(); + } +} 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 5c164eed..9e58b207 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -29,6 +29,8 @@ import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMode; import '../../../mixins/grid_focus_node_mixin.dart'; import '../../../mixins/item_updatable.dart'; +import '../../../mixins/deletion_aware.dart'; +import '../../../utils/deletion_notifier.dart'; import '../../../utils/platform_detector.dart'; import '../../../i18n/strings.g.dart'; import '../../main_screen.dart'; @@ -53,10 +55,65 @@ class LibraryBrowseTab extends BaseLibraryTab { } class _LibraryBrowseTabState extends BaseLibraryTabState - with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin { + with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware { @override PlexClient get client => 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 + final index = items.indexWhere((e) => e.ratingKey == event.ratingKey); + if (index != -1) { + setState(() { + items.removeAt(index); + }); + return; + } + + // If a child item was delete, then update our list to reflect that. + // If all children were deleted, remove our item. + // Otherwise, just update the counts. + for (final parentKey in event.parentChain) { + final parentIndex = items.indexWhere((e) => e.ratingKey == parentKey); + if (parentIndex != -1) { + final item = items[parentIndex]; + final newLeafCount = (item.leafCount ?? 1) - event.leafCount; + if (newLeafCount <= 0) { + setState(() { + items.removeAt(parentIndex); + }); + } else { + setState(() { + items[parentIndex] = item.copyWith(leafCount: newLeafCount); + }); + } + return; + } + } + } + @override String get focusNodeDebugLabel => 'browse_first_item'; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index f32dc7d9..8a0eebe3 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -38,7 +38,9 @@ import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../mixins/watch_state_aware.dart'; +import '../mixins/deletion_aware.dart'; import '../utils/watch_state_notifier.dart'; +import '../utils/deletion_notifier.dart'; import 'season_detail_screen.dart'; class MediaDetailScreen extends StatefulWidget { @@ -51,7 +53,7 @@ class MediaDetailScreen extends StatefulWidget { State createState() => _MediaDetailScreenState(); } -class _MediaDetailScreenState extends State with WatchStateAware { +class _MediaDetailScreenState extends State with WatchStateAware, DeletionAware { List _seasons = []; bool _isLoadingSeasons = false; PlexMetadata? _fullMetadata; @@ -74,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 { @@ -84,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 @@ -92,6 +112,81 @@ class _MediaDetailScreenState extends State with WatchStateAw } } + @override + Set? get deletionRatingKeys { + final keys = {widget.metadata.ratingKey}; + for (final season in _seasons) { + keys.add(season.ratingKey); + } + 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; + + // If we have a season that matches the rating key exactly, then remove it from our list + final seasonIndex = _seasons.indexWhere((s) => s.ratingKey == event.ratingKey); + if (seasonIndex != -1) { + setState(() { + _seasons.removeAt(seasonIndex); + }); + + // If the show has no more seasons, navigate back up to the library + if (_seasons.isEmpty && mounted) { + Navigator.of(context).pop(); + return; + } + _refreshWatchState(); + return; + } + + // If a child item was delete, then update our list to reflect that. + // If all children were deleted, remove our item. + // Otherwise, just update the counts. + for (final parentKey in event.parentChain) { + final idx = _seasons.indexWhere((s) => s.ratingKey == parentKey); + if (idx != -1) { + final season = _seasons[idx]; + final newLeafCount = (season.leafCount ?? 1) - 1; + if (newLeafCount <= 0) { + // Season is now empty, remove it + setState(() { + _seasons.removeAt(idx); + }); + + // Otherwise we have no more seasons, so navigate up + if (_seasons.isEmpty && mounted) { + Navigator.of(context).pop(); + return; + } + } else { + setState(() { + // Otherwise just update the counts + _seasons[idx] = season.copyWith(leafCount: newLeafCount); + }); + } + _refreshWatchState(); + return; + } + } + } + /// Lightweight refresh for watch state changes - no loader, preserves scroll Future _refreshWatchState() async { final client = _getClientForMetadata(context); diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 8dd1f566..d9f1d564 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -24,7 +24,9 @@ import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../mixins/item_updatable.dart'; import '../mixins/watch_state_aware.dart'; +import '../mixins/deletion_aware.dart'; import '../utils/watch_state_notifier.dart'; +import '../utils/deletion_notifier.dart'; import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; @@ -38,7 +40,8 @@ class SeasonDetailScreen extends StatefulWidget { State createState() => _SeasonDetailScreenState(); } -class _SeasonDetailScreenState extends State with ItemUpdatable, WatchStateAware, RouteAware { +class _SeasonDetailScreenState extends State + with ItemUpdatable, WatchStateAware, DeletionAware, RouteAware { PlexClient? _client; @override @@ -52,10 +55,23 @@ class _SeasonDetailScreenState extends State with ItemUpdata 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 @@ -64,6 +80,41 @@ class _SeasonDetailScreenState extends State with ItemUpdata } } + @override + Set? get deletionRatingKeys { + final keys = _episodes.map((e) => e.ratingKey).toSet(); + keys.add(widget.season.ratingKey); + 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 + final index = _episodes.indexWhere((e) => e.ratingKey == event.ratingKey); + if (index != -1) { + setState(() { + _episodes.removeAt(index); + }); + // If that was the last episode, navigate back to the show view + if (_episodes.isEmpty && mounted) { + Navigator.of(context).pop(); + } + } + } + /// Get the correct PlexClient for this season's server PlexClient? _getClientForSeason(BuildContext context) { if (widget.isOffline || widget.season.serverId == null) { diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart new file mode 100644 index 00000000..374ec223 --- /dev/null +++ b/lib/utils/deletion_notifier.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import '../models/plex_metadata.dart'; +import 'app_logger.dart'; + +/// Event representing a media item deletion with parent chain for hierarchical invalidation +class DeletionEvent { + /// The ratingKey of the deleted item + final String ratingKey; + + /// Composite key: serverId:ratingKey + final String globalKey; + + /// Server this item belongs to + final String serverId; + + /// Parent chain for hierarchical invalidation + /// For an episode: [seasonRatingKey, showRatingKey] + /// For a season: [showRatingKey] + /// For a movie: [] + final List parentChain; + + /// Media type of the deleted item + final String mediaType; + + /// Number of leaf items (episodes) contained in the deleted item. + /// For an episode: 1. For a season: its episode count. For a show: its total episode count. + final int leafCount; + + DeletionEvent({ + required this.ratingKey, + required this.serverId, + required this.parentChain, + required this.mediaType, + this.leafCount = 1, + }) : globalKey = '$serverId:$ratingKey'; + + /// Check if this event affects a specific item by ratingKey + bool affectsItem(String ratingKey) => this.ratingKey == ratingKey || parentChain.contains(ratingKey); + + /// Check if this event affects a specific globalKey + bool affectsGlobalKey(String globalKey) => + this.globalKey == globalKey || parentChain.any((pk) => '$serverId:$pk' == globalKey); + + /// 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)'; +} + +/// Notifier for media deletion events across the app. +/// +/// Singleton pattern following [WatchStateNotifier]. Screens subscribe +/// to receive events when items are deleted from the server. +class DeletionNotifier { + static final DeletionNotifier _instance = DeletionNotifier._internal(); + + factory DeletionNotifier() => _instance; + + DeletionNotifier._internal(); + + StreamController? _controller; + + StreamController get _ensureController { + if (_controller == null || _controller!.isClosed) { + _controller = StreamController.broadcast(); + } + return _controller!; + } + + /// Stream of all deletion events + Stream get stream => _ensureController.stream; + + /// Filter for events affecting a specific server + Stream forServer(String serverId) => stream.where((e) => e.serverId == serverId); + + /// Filter for events affecting a specific item or its children + Stream forItem(String ratingKey) => stream.where((e) => e.affectsItem(ratingKey)); + + /// Emit a deletion event + void notify(DeletionEvent event) { + appLogger.d('DeletionNotifier: $event'); + _ensureController.add(event); + } + + /// Helper to emit a deletion event from metadata + void notifyDeleted({required PlexMetadata metadata}) { + notify( + DeletionEvent( + ratingKey: metadata.ratingKey, + serverId: metadata.serverId ?? '', + parentChain: _buildParentChain(metadata), + mediaType: metadata.type, + leafCount: metadata.leafCount ?? 1, + ), + ); + } + + /// Build parent chain from metadata's parent keys + List _buildParentChain(PlexMetadata metadata) { + final chain = []; + if (metadata.parentRatingKey != null) { + chain.add(metadata.parentRatingKey!); + } + if (metadata.grandparentRatingKey != null) { + chain.add(metadata.grandparentRatingKey!); + } + return chain; + } + + void dispose() { + _controller?.close(); + _controller = null; + } +} 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 d774f797..61f248ab 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -20,6 +20,7 @@ import '../focus/dpad_navigator.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../utils/smart_deletion_handler.dart'; +import '../utils/deletion_notifier.dart'; import '../theme/mono_tokens.dart'; import '../widgets/file_info_bottom_sheet.dart'; import '../widgets/focusable_bottom_sheet.dart'; @@ -1070,7 +1071,9 @@ class MediaContextMenuState extends State { if (context.mounted) { if (success) { showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully); - // Trigger list refresh to remove the item from the view + // 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);