diff --git a/lib/mixins/watch_state_aware.dart b/lib/mixins/watch_state_aware.dart new file mode 100644 index 00000000..1da5f006 --- /dev/null +++ b/lib/mixins/watch_state_aware.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../utils/watch_state_notifier.dart'; + +/// Mixin for screens that need to react to watch state changes. +/// +/// Provides automatic subscription management and filtering based on +/// which items the screen cares about. +/// +/// Example usage: +/// ```dart +/// class _MyScreenState extends State with WatchStateAware { +/// List _items = []; +/// +/// @override +/// Set? get watchedRatingKeys => +/// _items.map((e) => e.ratingKey).toSet(); +/// +/// @override +/// void onWatchStateChanged(WatchStateEvent event) { +/// // Refresh affected item +/// _refreshItem(event.ratingKey); +/// } +/// } +/// ``` +mixin WatchStateAware on State { + StreamSubscription? _watchStateSubscription; + + /// Override to specify which ratingKeys this screen cares about. + /// + /// Return null to receive ALL events (not recommended for performance). + /// Return an empty set to receive no events. + /// + /// The set should include: + /// - Direct items displayed (e.g., episode ratingKeys in a season view) + /// - Parent items that affect display (e.g., show ratingKey for on-deck) + Set? get watchedRatingKeys; + + /// Called when a relevant watch state change occurs. + /// + /// Only called if [watchedRatingKeys] is null or contains an affected key. + void onWatchStateChanged(WatchStateEvent event); + + @override + void initState() { + super.initState(); + _subscribeToWatchState(); + } + + void _subscribeToWatchState() { + _watchStateSubscription = WatchStateNotifier().stream.listen((event) { + if (!mounted) return; + + final keys = watchedRatingKeys; + // If keys is null, receive all events + // Otherwise, filter to events that affect our keys + if (keys == null || event.affectsAnyOf(keys)) { + onWatchStateChanged(event); + } + }); + } + + @override + void dispose() { + _watchStateSubscription?.cancel(); + _watchStateSubscription = null; + super.dispose(); + } +} diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index 26762f9e..a755040a 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../models/plex_metadata.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/plex_api_cache.dart'; +import '../utils/watch_state_notifier.dart'; import 'download_provider.dart'; /// Provider for offline watch status UI state. @@ -168,17 +169,57 @@ class OfflineWatchProvider extends ChangeNotifier { /// Mark an item as watched while offline. /// - /// This queues the action for sync when online. + /// This queues the action for sync when online and emits a [WatchStateEvent]. Future markAsWatched({required String serverId, required String ratingKey}) async { await _syncService.queueMarkWatched(serverId: serverId, ratingKey: ratingKey); + + // Emit event for immediate UI update + final globalKey = '$serverId:$ratingKey'; + final metadata = _downloadProvider.getMetadata(globalKey); + if (metadata != null) { + WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true); + } else { + // Fallback: emit minimal event without parent chain + WatchStateNotifier().notify( + WatchStateEvent( + ratingKey: ratingKey, + serverId: serverId, + changeType: WatchStateChangeType.watched, + parentChain: [], + mediaType: 'unknown', + isNowWatched: true, + ), + ); + } + notifyListeners(); } /// Mark an item as unwatched while offline. /// - /// This queues the action for sync when online. + /// This queues the action for sync when online and emits a [WatchStateEvent]. Future markAsUnwatched({required String serverId, required String ratingKey}) async { await _syncService.queueMarkUnwatched(serverId: serverId, ratingKey: ratingKey); + + // Emit event for immediate UI update + final globalKey = '$serverId:$ratingKey'; + final metadata = _downloadProvider.getMetadata(globalKey); + if (metadata != null) { + WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false); + } else { + // Fallback: emit minimal event without parent chain + WatchStateNotifier().notify( + WatchStateEvent( + ratingKey: ratingKey, + serverId: serverId, + changeType: WatchStateChangeType.unwatched, + parentChain: [], + mediaType: 'unknown', + isNowWatched: false, + ), + ); + } + notifyListeners(); } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index f174fda4..6d6e62ea 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -22,6 +22,8 @@ import '../providers/settings_provider.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; import '../mixins/item_updatable.dart'; +import '../mixins/watch_state_aware.dart'; +import '../utils/watch_state_notifier.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; @@ -42,7 +44,7 @@ class DiscoverScreen extends StatefulWidget { } class _DiscoverScreenState extends State - with Refreshable, FullRefreshable, ItemUpdatable, SingleTickerProviderStateMixin { + with Refreshable, FullRefreshable, ItemUpdatable, WatchStateAware, SingleTickerProviderStateMixin { static const Duration _heroAutoScrollDuration = Duration(seconds: 8); @override @@ -66,6 +68,28 @@ class _DiscoverScreenState extends State late AnimationController _indicatorAnimationController; bool _isAutoScrollPaused = false; + // WatchStateAware: watch on-deck items and their parent shows/seasons + @override + Set? get watchedRatingKeys { + final keys = {}; + for (final item in _onDeck) { + keys.add(item.ratingKey); + if (item.parentRatingKey != null) { + keys.add(item.parentRatingKey!); + } + if (item.grandparentRatingKey != null) { + keys.add(item.grandparentRatingKey!); + } + } + return keys; + } + + @override + void onWatchStateChanged(WatchStateEvent event) { + // Refresh continue watching when any relevant item changes + _refreshContinueWatching(); + } + // Hub navigation keys GlobalKey? _continueWatchingHubKey; final List> _hubKeys = []; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 27d0eb43..89682cba 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -30,6 +30,8 @@ import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; +import '../mixins/watch_state_aware.dart'; +import '../utils/watch_state_notifier.dart'; import 'season_detail_screen.dart'; class MediaDetailScreen extends StatefulWidget { @@ -42,7 +44,7 @@ class MediaDetailScreen extends StatefulWidget { State createState() => _MediaDetailScreenState(); } -class _MediaDetailScreenState extends State { +class _MediaDetailScreenState extends State with WatchStateAware { List _seasons = []; bool _isLoadingSeasons = false; PlexMetadata? _fullMetadata; @@ -53,6 +55,67 @@ class _MediaDetailScreenState extends State { bool _watchStateChanged = false; double _scrollOffset = 0; + // WatchStateAware: watch the show/movie and all season ratingKeys + @override + Set? get watchedRatingKeys { + final keys = {widget.metadata.ratingKey}; + for (final season in _seasons) { + keys.add(season.ratingKey); + } + return keys; + } + + @override + void onWatchStateChanged(WatchStateEvent event) { + // Lightweight refresh - no loader, preserves scroll position + if (!widget.isOffline) { + _refreshWatchState(); + } + } + + /// Lightweight refresh for watch state changes - no loader, preserves scroll + Future _refreshWatchState() async { + final client = _getClientForMetadata(context); + if (client == null) return; + + try { + // Fetch updated metadata + on-deck without showing loader + final result = await client.getMetadataWithImagesAndOnDeck(widget.metadata.ratingKey); + final metadata = result['metadata'] as PlexMetadata?; + final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; + + if (metadata != null && mounted) { + setState(() { + _fullMetadata = metadata.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + ); + _onDeckEpisode = onDeckEpisode?.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + ); + }); + } + + // Refresh seasons for updated watched counts (also without loader) + if (widget.metadata.isShow) { + final seasons = await client.getChildren(widget.metadata.ratingKey); + if (mounted) { + setState(() { + _seasons = seasons + .map((s) => s.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + )) + .toList(); + }); + } + } + } catch (e) { + // Silently fail - data will refresh on next navigation + } + } + @override void initState() { super.initState(); diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 36a302aa..7d44e2f2 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -19,6 +19,8 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../mixins/item_updatable.dart'; +import '../mixins/watch_state_aware.dart'; +import '../utils/watch_state_notifier.dart'; import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; @@ -32,7 +34,7 @@ class SeasonDetailScreen extends StatefulWidget { State createState() => _SeasonDetailScreenState(); } -class _SeasonDetailScreenState extends State with ItemUpdatable { +class _SeasonDetailScreenState extends State with ItemUpdatable, WatchStateAware { PlexClient? _client; @override @@ -44,6 +46,18 @@ class _SeasonDetailScreenState extends State with ItemUpdata // Capture keyboard mode once at init to avoid rebuild dependency bool _initialKeyboardMode = false; + // WatchStateAware: watch all episode ratingKeys + @override + Set? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet(); + + @override + void onWatchStateChanged(WatchStateEvent event) { + // Update the affected episode + if (!widget.isOffline && _client != null) { + updateItem(event.ratingKey); + } + } + /// Get the correct PlexClient for this season's server PlexClient? _getClientForSeason(BuildContext context) { if (widget.isOffline || widget.season.serverId == null) { diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 47608b57..1a8cb128 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -6,6 +6,7 @@ import 'plex_client.dart'; import 'offline_watch_sync_service.dart'; import '../models/plex_metadata.dart'; import '../utils/app_logger.dart'; +import '../utils/watch_state_notifier.dart'; /// Tracks playback progress and reports it to the Plex server. /// @@ -103,6 +104,15 @@ class PlaybackProgressTracker { // Send progress to server immediately await _sendOnlineProgress(state, position, duration); } + + // Emit watch state event on stop for UI updates across screens + if (state == 'stopped' && position.inMilliseconds > 0) { + WatchStateNotifier().notifyProgress( + metadata: metadata, + viewOffset: position.inMilliseconds, + duration: duration.inMilliseconds, + ); + } } catch (e) { appLogger.d('Failed to send progress update (non-critical)', error: e); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index d12b0741..d304669e 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -20,6 +20,7 @@ import '../utils/app_logger.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/plex_cache_parser.dart'; import '../utils/plex_url_helper.dart'; +import '../utils/watch_state_notifier.dart'; import 'plex_api_cache.dart'; /// Constants for Plex stream types @@ -1123,13 +1124,23 @@ class PlexClient { } /// Mark media as watched - Future markAsWatched(String ratingKey) async { + /// + /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. + Future markAsWatched(String ratingKey, {PlexMetadata? metadata}) async { await _dio.get('/:/scrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); + if (metadata != null) { + WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true); + } } /// Mark media as unwatched - Future markAsUnwatched(String ratingKey) async { + /// + /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. + Future markAsUnwatched(String ratingKey, {PlexMetadata? metadata}) async { await _dio.get('/:/unscrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); + if (metadata != null) { + WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false); + } } /// Update playback progress diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart new file mode 100644 index 00000000..1325b86d --- /dev/null +++ b/lib/utils/watch_state_notifier.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import '../models/plex_metadata.dart'; +import 'app_logger.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 { + /// The item that changed + final String ratingKey; + + /// Composite key: serverId:ratingKey + final String globalKey; + + /// Server this item belongs to + final String serverId; + + /// Type of change + final WatchStateChangeType changeType; + + /// Parent chain for hierarchical invalidation + /// For an episode: [seasonRatingKey, showRatingKey] + /// For a season: [showRatingKey] + /// For a movie: [] + 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; + + WatchStateEvent({ + required this.ratingKey, + required this.serverId, + required this.changeType, + required this.parentChain, + required this.mediaType, + this.viewOffset, + 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); + + @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 { + 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 + void notify(WatchStateEvent event) { + appLogger.d('WatchStateNotifier: $event'); + _ensureController.add(event); + } + + /// Helper to emit a watched/unwatched event from metadata + void notifyWatched({required PlexMetadata metadata, bool isNowWatched = true}) { + notify( + WatchStateEvent( + ratingKey: metadata.ratingKey, + serverId: metadata.serverId ?? '', + changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched, + parentChain: _buildParentChain(metadata), + mediaType: metadata.type, + isNowWatched: isNowWatched, + ), + ); + } + + /// Helper to emit a progress update event + void notifyProgress({required PlexMetadata metadata, required int viewOffset, required int duration}) { + const threshold = 0.90; + final isNowWatched = duration > 0 && (viewOffset / duration) >= threshold; + + notify( + WatchStateEvent( + ratingKey: metadata.ratingKey, + serverId: metadata.serverId ?? '', + changeType: WatchStateChangeType.progressUpdate, + parentChain: _buildParentChain(metadata), + mediaType: metadata.type, + viewOffset: viewOffset, + isNowWatched: isNowWatched, + ), + ); + } + + /// Build parent chain from metadata's parent keys + List _buildParentChain(PlexMetadata metadata) { + final chain = []; + if (metadata.parentRatingKey != null) { + chain.add(metadata.parentRatingKey!); + } + if (metadata.grandparentRatingKey != null) { + chain.add(metadata.grandparentRatingKey!); + } + return chain; + } + + void dispose() { + _controller?.close(); + _controller = null; + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 164b6fab..ceec4b1d 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -289,7 +289,7 @@ class MediaContextMenuState extends State { switch (selected) { case 'watch': if (isOffline && metadata?.serverId != null) { - // Offline mode: queue action for later sync + // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); await offlineWatch.markAsWatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey); if (context.mounted) { @@ -297,13 +297,18 @@ class MediaContextMenuState extends State { widget.onRefresh?.call(metadata.ratingKey); } } else { - await _executeAction(context, () => client.markAsWatched(metadata!.ratingKey), t.messages.markedAsWatched); + // Pass metadata to emit WatchStateEvent for cross-screen updates + await _executeAction( + context, + () => client.markAsWatched(metadata!.ratingKey, metadata: metadata), + t.messages.markedAsWatched, + ); } break; case 'unwatch': if (isOffline && metadata?.serverId != null) { - // Offline mode: queue action for later sync + // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); await offlineWatch.markAsUnwatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey); if (context.mounted) { @@ -311,9 +316,10 @@ class MediaContextMenuState extends State { widget.onRefresh?.call(metadata.ratingKey); } } else { + // Pass metadata to emit WatchStateEvent for cross-screen updates await _executeAction( context, - () => client.markAsUnwatched(metadata!.ratingKey), + () => client.markAsUnwatched(metadata!.ratingKey, metadata: metadata), t.messages.markedAsUnwatched, ); }