refactor: extract BaseNotifier and HierarchicalEventMixin
This commit is contained in:
@@ -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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user