Improve media deletion propagation throughout the app

This commit is contained in:
Micah Morrison
2026-02-05 21:55:04 -05:00
parent abcafebf7a
commit f6aa8e51e0
6 changed files with 316 additions and 5 deletions
+70
View File
@@ -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<MyScreen> with DeletionAware {
/// List<PlexMetadata> _items = [];
///
/// @override
/// Set<String>? get deletionRatingKeys =>
/// _items.map((e) => e.ratingKey).toSet();
///
/// @override
/// void onDeletionEvent(DeletionEvent event) {
/// setState(() {
/// _items.removeWhere((e) => e.ratingKey == event.ratingKey);
/// });
/// }
/// }
/// ```
mixin DeletionAware<T extends StatefulWidget> on State<T> {
StreamSubscription<DeletionEvent>? _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<String>? 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();
}
}
@@ -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<PlexMetadata> {
}
class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin {
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware {
@override
PlexClient get client => getClientForLibrary();
@override
Set<String>? 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';
+63 -1
View File
@@ -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<MediaDetailScreen> createState() => _MediaDetailScreenState();
}
class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAware {
class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAware, DeletionAware {
List<PlexMetadata> _seasons = [];
bool _isLoadingSeasons = false;
PlexMetadata? _fullMetadata;
@@ -92,6 +94,66 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
}
}
@override
Set<String>? get deletionRatingKeys {
final keys = <String>{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<void> _refreshWatchState() async {
final client = _getClientForMetadata(context);
+26 -1
View File
@@ -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<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
}
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable, WatchStateAware, RouteAware {
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
with ItemUpdatable, WatchStateAware, DeletionAware, RouteAware {
PlexClient? _client;
@override
@@ -64,6 +67,28 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
}
}
@override
Set<String>? 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) {
+115
View File
@@ -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<String> 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<String> 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<DeletionEvent>? _controller;
StreamController<DeletionEvent> get _ensureController {
if (_controller == null || _controller!.isClosed) {
_controller = StreamController<DeletionEvent>.broadcast();
}
return _controller!;
}
/// Stream of all deletion events
Stream<DeletionEvent> get stream => _ensureController.stream;
/// Filter for events affecting a specific server
Stream<DeletionEvent> forServer(String serverId) => stream.where((e) => e.serverId == serverId);
/// Filter for events affecting a specific item or its children
Stream<DeletionEvent> 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<String> _buildParentChain(PlexMetadata metadata) {
final chain = <String>[];
if (metadata.parentRatingKey != null) {
chain.add(metadata.parentRatingKey!);
}
if (metadata.grandparentRatingKey != null) {
chain.add(metadata.grandparentRatingKey!);
}
return chain;
}
void dispose() {
_controller?.close();
_controller = null;
}
}
+3 -2
View File
@@ -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<MediaContextMenu> {
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);
}