diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index fbca12e1..64f135a9 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -177,6 +177,19 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _onReorder(int oldIndex, int newIndex) async { // Adjust newIndex if moving down in the list if (newIndex > oldIndex) { @@ -198,22 +211,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _removeItem(int index) async { final item = items[index]; diff --git a/lib/utils/base_notifier.dart b/lib/utils/base_notifier.dart new file mode 100644 index 00000000..85f32e37 --- /dev/null +++ b/lib/utils/base_notifier.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +/// Base class for singleton notifiers with broadcast stream support. +/// +/// Provides reusable stream controller management with lazy initialization +/// and automatic recreation if disposed. Subclasses define the event type [T]. +abstract class BaseNotifier { + StreamController? _controller; + + /// Ensure controller exists (creates if null or closed). + StreamController get _ensureController { + if (_controller == null || _controller!.isClosed) { + _controller = StreamController.broadcast(); + } + return _controller!; + } + + /// Stream of all events. + Stream get stream => _ensureController.stream; + + /// Emit an event to all listeners. + void notify(T event) => _ensureController.add(event); + + /// Dispose controller (can be reinitialized later by accessing stream). + void dispose() { + _controller?.close(); + _controller = null; + } +} diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index 374ec223..2b38c6f6 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -1,22 +1,27 @@ -import 'dart:async'; import '../models/plex_metadata.dart'; import 'app_logger.dart'; +import 'base_notifier.dart'; +import 'hierarchical_event_mixin.dart'; /// Event representing a media item deletion with parent chain for hierarchical invalidation -class DeletionEvent { +class DeletionEvent with HierarchicalEventMixin { /// The ratingKey of the deleted item + @override final String ratingKey; /// Composite key: serverId:ratingKey + @override final String globalKey; /// Server this item belongs to + @override final String serverId; /// Parent chain for hierarchical invalidation /// For an episode: [seasonRatingKey, showRatingKey] /// For a season: [showRatingKey] /// For a movie: [] + @override final List parentChain; /// Media type of the deleted item @@ -34,19 +39,6 @@ class DeletionEvent { 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)'; } @@ -55,35 +47,24 @@ class DeletionEvent { /// /// Singleton pattern following [WatchStateNotifier]. Screens subscribe /// to receive events when items are deleted from the server. -class DeletionNotifier { +class DeletionNotifier extends BaseNotifier { 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 + /// Emit a deletion event with logging + @override void notify(DeletionEvent event) { appLogger.d('DeletionNotifier: $event'); - _ensureController.add(event); + super.notify(event); } /// Helper to emit a deletion event from metadata @@ -110,9 +91,4 @@ class DeletionNotifier { } return chain; } - - void dispose() { - _controller?.close(); - _controller = null; - } } diff --git a/lib/utils/hierarchical_event_mixin.dart b/lib/utils/hierarchical_event_mixin.dart new file mode 100644 index 00000000..a1599e55 --- /dev/null +++ b/lib/utils/hierarchical_event_mixin.dart @@ -0,0 +1,34 @@ +/// Mixin providing hierarchical event matching methods. +/// +/// Events that represent changes to media items often need to check if they +/// affect a specific item or any of its parents in the hierarchy. This mixin +/// provides common matching logic for such events. +mixin HierarchicalEventMixin { + /// The ratingKey of the affected item. + String get ratingKey; + + /// Composite key: serverId:ratingKey. + String get globalKey; + + /// Server this item belongs to. + String get serverId; + + /// Parent chain for hierarchical matching. + /// For an episode: [seasonRatingKey, showRatingKey] + /// For a season: [showRatingKey] + /// For a movie: [] + List get parentChain; + + /// 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); +} diff --git a/lib/utils/library_refresh_notifier.dart b/lib/utils/library_refresh_notifier.dart index 85941a21..8c6c9b58 100644 --- a/lib/utils/library_refresh_notifier.dart +++ b/lib/utils/library_refresh_notifier.dart @@ -1,4 +1,4 @@ -import 'dart:async'; +import 'base_notifier.dart'; /// Types of library refresh events enum LibraryRefreshType { collections, playlists } @@ -7,27 +7,13 @@ enum LibraryRefreshType { collections, playlists } /// /// Singleton pattern with reinitializable state. The controller is lazily /// created and automatically recreated if disposed and later accessed. -class LibraryRefreshNotifier { +class LibraryRefreshNotifier extends BaseNotifier { static final LibraryRefreshNotifier _instance = LibraryRefreshNotifier._internal(); factory LibraryRefreshNotifier() => _instance; LibraryRefreshNotifier._internal(); - /// Unified stream controller (lazily created, reinitializable) - StreamController? _controller; - - /// Ensure controller exists (creates if null or closed) - StreamController get _ensureController { - if (_controller == null || _controller!.isClosed) { - _controller = StreamController.broadcast(); - } - return _controller!; - } - - /// Unified stream of all refresh events - Stream get stream => _ensureController.stream; - /// Stream for collections tab (backward compatible) Stream get collectionsStream => stream.where((t) => t == LibraryRefreshType.collections).map((_) {}); @@ -36,17 +22,11 @@ class LibraryRefreshNotifier { /// Notify that collections have changed void notifyCollectionsChanged() { - _ensureController.add(LibraryRefreshType.collections); + notify(LibraryRefreshType.collections); } /// Notify that playlists have changed void notifyPlaylistsChanged() { - _ensureController.add(LibraryRefreshType.playlists); - } - - /// Dispose controller (can be reinitialized later by accessing stream) - void dispose() { - _controller?.close(); - _controller = null; + notify(LibraryRefreshType.playlists); } } diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 03310307..810c2d51 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -1,19 +1,23 @@ -import 'dart:async'; import '../models/plex_metadata.dart'; import 'app_logger.dart'; +import 'base_notifier.dart'; +import 'hierarchical_event_mixin.dart'; /// Types of watch state changes enum WatchStateChangeType { watched, unwatched, progressUpdate } /// Event representing a watch state change with parent chain for hierarchical invalidation -class WatchStateEvent { +class WatchStateEvent with HierarchicalEventMixin { /// The item that changed + @override final String ratingKey; /// Composite key: serverId:ratingKey + @override final String globalKey; /// Server this item belongs to + @override final String serverId; /// Type of change @@ -23,6 +27,7 @@ class WatchStateEvent { /// For an episode: [seasonRatingKey, showRatingKey] /// For a season: [showRatingKey] /// For a movie: [] + @override final List parentChain; /// Media type that changed @@ -44,19 +49,6 @@ class WatchStateEvent { this.isNowWatched, }) : 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() => 'WatchStateEvent($changeType, $globalKey, parents: $parentChain)'; } @@ -65,35 +57,24 @@ class WatchStateEvent { /// /// Singleton pattern following [LibraryRefreshNotifier]. Screens subscribe /// to receive events when items are marked watched/unwatched or progress updates. -class WatchStateNotifier { +class WatchStateNotifier extends BaseNotifier { static final WatchStateNotifier _instance = WatchStateNotifier._internal(); factory WatchStateNotifier() => _instance; WatchStateNotifier._internal(); - StreamController? _controller; - - StreamController get _ensureController { - if (_controller == null || _controller!.isClosed) { - _controller = StreamController.broadcast(); - } - return _controller!; - } - - /// Stream of all watch state 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 watch state event + /// Emit a watch state event with logging + @override void notify(WatchStateEvent event) { appLogger.d('WatchStateNotifier: $event'); - _ensureController.add(event); + super.notify(event); } /// Helper to emit a watched/unwatched event from metadata @@ -139,9 +120,4 @@ class WatchStateNotifier { } return chain; } - - void dispose() { - _controller?.close(); - _controller = null; - } }