153 lines
4.9 KiB
Dart
153 lines
4.9 KiB
Dart
import '../media/media_item.dart';
|
|
import '../media/ids.dart';
|
|
import 'app_logger.dart';
|
|
import 'base_notifier.dart';
|
|
import 'global_key_utils.dart';
|
|
import 'hierarchical_event_mixin.dart';
|
|
|
|
enum WatchStateChangeType { watched, unwatched, progressUpdate, removedFromContinueWatching }
|
|
|
|
/// Event representing a watch state change with parent chain for hierarchical invalidation
|
|
class WatchStateEvent with HierarchicalEventMixin {
|
|
/// The id of the item that changed (Plex ratingKey, Jellyfin GUID, …).
|
|
@override
|
|
final String itemId;
|
|
|
|
/// Composite key: serverId:itemId
|
|
@override
|
|
final String globalKey;
|
|
|
|
/// Server this item belongs to
|
|
@override
|
|
final ServerId serverId;
|
|
|
|
/// Optional backend-private cache namespace for user-scoped servers.
|
|
///
|
|
/// UI invalidation still uses [serverId], but cache writers should prefer
|
|
/// this when present so Jellyfin user data stays isolated per user.
|
|
final String? cacheServerId;
|
|
|
|
/// Type of change
|
|
final WatchStateChangeType changeType;
|
|
|
|
/// Parent chain for hierarchical invalidation
|
|
/// For an episode: [seasonId, showId]
|
|
/// For a season: [showId]
|
|
/// For a movie: []
|
|
@override
|
|
final List<String> parentChain;
|
|
|
|
/// Media type that changed
|
|
final String mediaType;
|
|
|
|
/// New progress value (for progressUpdate)
|
|
final int? viewOffset;
|
|
|
|
/// Whether item is now considered watched (>90% progress or marked)
|
|
final bool? isNowWatched;
|
|
|
|
/// Library section this item belongs to — used for per-tracker library
|
|
/// filtering. Null when emitted without full metadata. Plex sends a
|
|
/// numeric id, Jellyfin sends a UUID; both round-trip as strings.
|
|
final String? librarySectionID;
|
|
|
|
WatchStateEvent({
|
|
required this.itemId,
|
|
required this.serverId,
|
|
required this.changeType,
|
|
required this.parentChain,
|
|
required this.mediaType,
|
|
this.cacheServerId,
|
|
this.viewOffset,
|
|
this.isNowWatched,
|
|
this.librarySectionID,
|
|
}) : globalKey = buildGlobalKey(ServerId(serverId), itemId);
|
|
|
|
/// `serverId:librarySectionID`, matching [MediaLibrary.globalKey]. Null when
|
|
/// the library section is unknown; tracker filters treat unknown as allowed
|
|
/// only when no filter is configured.
|
|
String? get librarySectionGlobalKey =>
|
|
librarySectionID != null ? buildGlobalKey(ServerId(serverId), librarySectionID!) : null;
|
|
|
|
@override
|
|
String toString() => 'WatchStateEvent($changeType, $globalKey, parents: $parentChain)';
|
|
}
|
|
|
|
/// Notifier for watch state changes across the app.
|
|
///
|
|
/// Singleton pattern following [LibraryRefreshNotifier]. Screens subscribe
|
|
/// to receive events when items are marked watched/unwatched or progress updates.
|
|
class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
|
|
static final WatchStateNotifier _instance = WatchStateNotifier._internal();
|
|
|
|
factory WatchStateNotifier() => _instance;
|
|
|
|
WatchStateNotifier._internal();
|
|
|
|
Stream<WatchStateEvent> forServer(ServerId serverId) => stream.where((e) => e.serverId == serverId);
|
|
|
|
Stream<WatchStateEvent> forItem(String itemId) => stream.where((e) => e.affectsItem(itemId));
|
|
|
|
/// Emit a watch state event with logging
|
|
@override
|
|
void notify(WatchStateEvent event) {
|
|
appLogger.d('WatchStateNotifier: $event');
|
|
super.notify(event);
|
|
}
|
|
|
|
/// Helper to emit a watched/unwatched event from a [MediaItem].
|
|
void notifyWatched({required MediaItem item, bool isNowWatched = true, String? cacheServerId}) {
|
|
notify(
|
|
WatchStateEvent(
|
|
itemId: item.id,
|
|
serverId: ServerId(item.serverId ?? ''),
|
|
cacheServerId: cacheServerId,
|
|
changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched,
|
|
parentChain: item.parentChain,
|
|
mediaType: item.kind.id,
|
|
isNowWatched: isNowWatched,
|
|
librarySectionID: item.libraryId,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Helper to emit a progress update event.
|
|
/// [watchedThreshold] defaults to 0.9 — pass the server's configured value
|
|
/// (`client.watchedThreshold`) when available.
|
|
void notifyProgress({
|
|
required MediaItem item,
|
|
required int viewOffset,
|
|
required int duration,
|
|
double watchedThreshold = 0.9,
|
|
}) {
|
|
final isNowWatched = duration > 0 && (viewOffset / duration) >= watchedThreshold;
|
|
|
|
notify(
|
|
WatchStateEvent(
|
|
itemId: item.id,
|
|
serverId: ServerId(item.serverId ?? ''),
|
|
changeType: WatchStateChangeType.progressUpdate,
|
|
parentChain: item.parentChain,
|
|
mediaType: item.kind.id,
|
|
viewOffset: viewOffset,
|
|
isNowWatched: isNowWatched,
|
|
librarySectionID: item.libraryId,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Helper to emit a Continue Watching removal event.
|
|
void notifyRemovedFromContinueWatching({required MediaItem item}) {
|
|
notify(
|
|
WatchStateEvent(
|
|
itemId: item.id,
|
|
serverId: ServerId(item.serverId ?? ''),
|
|
changeType: WatchStateChangeType.removedFromContinueWatching,
|
|
parentChain: item.parentChain,
|
|
mediaType: item.kind.id,
|
|
librarySectionID: item.libraryId,
|
|
),
|
|
);
|
|
}
|
|
}
|