refactor: extract BaseNotifier and HierarchicalEventMixin

This commit is contained in:
edde746
2026-02-06 17:01:56 +01:00
parent 8eca2051e5
commit 44eae4ef84
6 changed files with 121 additions and 139 deletions
@@ -177,6 +177,19 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
}
/// Get the afterPlaylistItemId for reordering at the given index.
/// Returns null if validation fails, showing an error if [showError] is true.
int? _getAfterPlaylistItemId(int newIndex, {bool showError = true}) {
if (newIndex == 0) return 0;
final afterItem = items[newIndex - 1];
if (afterItem.playlistItemID == null) {
appLogger.e('Cannot reorder: after item missing playlistItemID');
if (showError && mounted) showErrorSnackBar(context, t.playlists.errorReordering);
return null;
}
return afterItem.playlistItemID!;
}
Future<void> _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<PlaylistDetai
}
// Determine the "after" item ID
// If moving to position 0, afterPlaylistItemId should be 0 (move to top)
// Otherwise, use the playlistItemID of the item before the new position
final int afterPlaylistItemId;
if (newIndex == 0) {
afterPlaylistItemId = 0; // Move to top
} else {
final afterItem = items[newIndex - 1];
if (afterItem.playlistItemID == null) {
appLogger.e('Cannot reorder: after item missing playlistItemID');
if (mounted) {
showErrorSnackBar(context, t.playlists.errorReordering);
}
return;
}
afterPlaylistItemId = afterItem.playlistItemID!;
}
final afterPlaylistItemId = _getAfterPlaylistItemId(newIndex);
if (afterPlaylistItemId == null) return;
appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)');
@@ -255,37 +254,20 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
appLogger.e('Cannot persist move: item missing playlistItemID');
if (mounted) {
showErrorSnackBar(context, t.playlists.errorReordering);
// Revert the UI change
setState(() {
final item = items.removeAt(newIndex);
items.insert(originalIndex, item);
_focusedIndex = originalIndex;
});
_revertMove(newIndex, originalIndex);
}
return;
}
// Determine the "after" item ID based on where the item is now
final int afterPlaylistItemId;
if (newIndex == 0) {
afterPlaylistItemId = 0; // Move to top
} else {
final afterItem = items[newIndex - 1];
if (afterItem.playlistItemID == null) {
appLogger.e('Cannot persist move: after item missing playlistItemID');
final afterPlaylistItemId = _getAfterPlaylistItemId(newIndex, showError: false);
if (afterPlaylistItemId == null) {
if (mounted) {
showErrorSnackBar(context, t.playlists.errorReordering);
// Revert the UI change
setState(() {
final item = items.removeAt(newIndex);
items.insert(originalIndex, item);
_focusedIndex = originalIndex;
});
_revertMove(newIndex, originalIndex);
}
return;
}
afterPlaylistItemId = afterItem.playlistItemID!;
}
appLogger.d('Persisting move from $originalIndex to $newIndex (after ID: $afterPlaylistItemId)');
@@ -300,16 +282,21 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
// Revert on failure
appLogger.e('Failed to persist move, reverting UI');
if (mounted) {
setState(() {
final item = items.removeAt(newIndex);
items.insert(originalIndex, item);
_focusedIndex = originalIndex;
});
_revertMove(newIndex, originalIndex);
showErrorSnackBar(context, t.playlists.errorReordering);
}
}
}
/// Revert a move in the UI by moving item from [fromIndex] back to [toIndex].
void _revertMove(int fromIndex, int toIndex) {
setState(() {
final item = items.removeAt(fromIndex);
items.insert(toIndex, item);
_focusedIndex = toIndex;
});
}
Future<void> _removeItem(int index) async {
final item = items[index];
+29
View File
@@ -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<T> {
StreamController<T>? _controller;
/// Ensure controller exists (creates if null or closed).
StreamController<T> get _ensureController {
if (_controller == null || _controller!.isClosed) {
_controller = StreamController<T>.broadcast();
}
return _controller!;
}
/// Stream of all events.
Stream<T> 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;
}
}
+11 -35
View File
@@ -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<String> 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<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)';
}
@@ -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<DeletionEvent> {
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
/// 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;
}
}
+34
View File
@@ -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<String> 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<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);
}
+4 -24
View File
@@ -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<LibraryRefreshType> {
static final LibraryRefreshNotifier _instance = LibraryRefreshNotifier._internal();
factory LibraryRefreshNotifier() => _instance;
LibraryRefreshNotifier._internal();
/// Unified stream controller (lazily created, reinitializable)
StreamController<LibraryRefreshType>? _controller;
/// Ensure controller exists (creates if null or closed)
StreamController<LibraryRefreshType> get _ensureController {
if (_controller == null || _controller!.isClosed) {
_controller = StreamController<LibraryRefreshType>.broadcast();
}
return _controller!;
}
/// Unified stream of all refresh events
Stream<LibraryRefreshType> get stream => _ensureController.stream;
/// Stream for collections tab (backward compatible)
Stream<void> 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);
}
}
+11 -35
View File
@@ -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<String> 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<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)';
}
@@ -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<WatchStateEvent> {
static final WatchStateNotifier _instance = WatchStateNotifier._internal();
factory WatchStateNotifier() => _instance;
WatchStateNotifier._internal();
StreamController<WatchStateEvent>? _controller;
StreamController<WatchStateEvent> get _ensureController {
if (_controller == null || _controller!.isClosed) {
_controller = StreamController<WatchStateEvent>.broadcast();
}
return _controller!;
}
/// Stream of all watch state events
Stream<WatchStateEvent> get stream => _ensureController.stream;
/// Filter for events affecting a specific server
Stream<WatchStateEvent> forServer(String serverId) => stream.where((e) => e.serverId == serverId);
/// Filter for events affecting a specific item or its children
Stream<WatchStateEvent> 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;
}
}