Merge branch 'feature/improve-deletion'

This commit is contained in:
edde746
2026-02-06 09:59:11 +01:00
9 changed files with 465 additions and 4 deletions
+92
View File
@@ -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<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 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<String>? 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<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 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();
}
}
+22
View File
@@ -26,6 +26,17 @@ import '../utils/watch_state_notifier.dart';
mixin WatchStateAware<T extends StatefulWidget> on State<T> {
StreamSubscription<WatchStateEvent>? _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<String>? 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<T extends StatefulWidget> on State<T> {
_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
+20
View File
@@ -75,6 +75,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final ValueNotifier<double> _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<String>? get watchedRatingKeys {
@@ -91,6 +93,24 @@ class _DiscoverScreenState extends State<DiscoverScreen>
return keys;
}
@override
Set<String>? get watchedGlobalKeys {
final keys = <String>{};
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
@@ -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<PlexMetadata> {
}
class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
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<String>? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet();
@override
Set<String>? get deletionGlobalKeys {
if (items.isEmpty) return <String>{};
final keys = <String>{};
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';
+96 -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;
@@ -74,6 +76,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
// GlobalKeys for season cards to access their context menu
final Map<int, GlobalKey<MediaCardState>> _seasonCardKeys = {};
String _toGlobalKey(String ratingKey, {String? serverId}) =>
'${serverId ?? widget.metadata.serverId ?? ''}:$ratingKey';
// WatchStateAware: watch the show/movie and all season ratingKeys
@override
Set<String>? get watchedRatingKeys {
@@ -84,6 +89,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
return keys;
}
@override
String? get watchStateServerId => widget.metadata.serverId;
@override
Set<String>? get watchedGlobalKeys {
final serverId = widget.metadata.serverId;
if (serverId == null) return null;
final keys = <String>{_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<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
String? get deletionServerId => widget.metadata.serverId;
@override
Set<String>? get deletionGlobalKeys {
final serverId = widget.metadata.serverId;
if (serverId == null) return null;
final keys = <String>{_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<void> _refreshWatchState() async {
final client = _getClientForMetadata(context);
+52 -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
@@ -52,10 +55,23 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> 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<String>? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet();
@override
String? get watchStateServerId => widget.season.serverId;
@override
Set<String>? 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<SeasonDetailScreen> with ItemUpdata
}
}
@override
Set<String>? 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<String>? 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) {
+118
View File
@@ -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<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);
/// Check if this event affects any item in a global-key collection
bool affectsAnyGlobalKey(Iterable<String> 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<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
View File
@@ -54,6 +54,9 @@ class WatchStateEvent {
/// Check if this event affects any item in a collection
bool affectsAnyOf(Iterable<String> ratingKeys) => ratingKeys.any(affectsItem);
/// Check if this event affects any item in a global-key collection
bool affectsAnyGlobalKey(Iterable<String> globalKeys) => globalKeys.any(affectsGlobalKey);
@override
String toString() => 'WatchStateEvent($changeType, $globalKey, parents: $parentChain)';
}
+4 -1
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,7 +1071,9 @@ 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
// 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);