import '../media/media_item.dart'; import '../media/ids.dart'; import '../media/watch_progress.dart'; import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; import 'hierarchical_event_mixin.dart'; import 'media_event_keys.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 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 { static final WatchStateNotifier _instance = WatchStateNotifier._internal(); factory WatchStateNotifier() => _instance; WatchStateNotifier._internal(); Stream forServer(ServerId serverId) => stream.where((e) => e.serverId == serverId); Stream 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}) { final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'watched'); if (serverId == null) return; notify( WatchStateEvent( itemId: item.id, serverId: 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, String? cacheServerId, double watchedThreshold = 0.9, }) { final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'progress'); if (serverId == null) return; final isNowWatched = isWatchedProgress(positionMs: viewOffset, durationMs: duration, threshold: watchedThreshold); notify( WatchStateEvent( itemId: item.id, serverId: serverId, cacheServerId: cacheServerId, 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}) { final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'continue-watching removal'); if (serverId == null) return; notify( WatchStateEvent( itemId: item.id, serverId: serverId, changeType: WatchStateChangeType.removedFromContinueWatching, parentChain: item.parentChain, mediaType: item.kind.id, librarySectionID: item.libraryId, ), ); } }