diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart new file mode 100644 index 00000000..f5201303 --- /dev/null +++ b/lib/mixins/deletion_aware.dart @@ -0,0 +1,70 @@ +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 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 keys = deletionRatingKeys; + // If keys is null, receive all events + // Otherwise, filter to events that affect our keys + if (keys == null || event.affectsAnyOf(keys)) { + onDeletionEvent(event); + } + }); + } + + @override + void dispose() { + _deletionSubscription?.cancel(); + _deletionSubscription = null; + super.dispose(); + } +} diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 5d02adb3..fa855873 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,46 @@ class LibraryBrowseTab extends BaseLibraryTab { } class _LibraryBrowseTabState extends BaseLibraryTabState - with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin { + with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware { @override PlexClient get client => getClientForLibrary(); + @override + Set? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet(); + + @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..3b448b14 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; @@ -92,6 +94,66 @@ 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 + 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..e6bde91f 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 @@ -64,6 +67,28 @@ 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 + 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..d24ef386 --- /dev/null +++ b/lib/utils/deletion_notifier.dart @@ -0,0 +1,115 @@ +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); + + @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/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index d774f797..9492389e 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,8 +1071,8 @@ class MediaContextMenuState extends State { if (context.mounted) { if (success) { showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully); - // Trigger list refresh to remove the item from the view - widget.onListRefresh?.call(); + // Broadcast deletion event for cross-screen propagation + DeletionNotifier().notifyDeleted(metadata: metadata); } else { showErrorSnackBar(context, t.mediaMenu.mediaFailedToDelete); }