feat: reactive watch state updates

This commit is contained in:
edde746
2026-01-01 22:05:55 +01:00
parent b36e585fd7
commit 1d67a5d992
9 changed files with 393 additions and 11 deletions
+69
View File
@@ -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<MyScreen> with WatchStateAware {
/// List<PlexMetadata> _items = [];
///
/// @override
/// Set<String>? get watchedRatingKeys =>
/// _items.map((e) => e.ratingKey).toSet();
///
/// @override
/// void onWatchStateChanged(WatchStateEvent event) {
/// // Refresh affected item
/// _refreshItem(event.ratingKey);
/// }
/// }
/// ```
mixin WatchStateAware<T extends StatefulWidget> on State<T> {
StreamSubscription<WatchStateEvent>? _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<String>? 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();
}
}
+43 -2
View File
@@ -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<void> 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<void> 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();
}
+25 -1
View File
@@ -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<DiscoverScreen>
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<DiscoverScreen>
late AnimationController _indicatorAnimationController;
bool _isAutoScrollPaused = false;
// WatchStateAware: watch on-deck items and their parent shows/seasons
@override
Set<String>? get watchedRatingKeys {
final keys = <String>{};
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<HubSectionState>? _continueWatchingHubKey;
final List<GlobalKey<HubSectionState>> _hubKeys = [];
+64 -1
View File
@@ -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<MediaDetailScreen> createState() => _MediaDetailScreenState();
}
class _MediaDetailScreenState extends State<MediaDetailScreen> {
class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAware {
List<PlexMetadata> _seasons = [];
bool _isLoadingSeasons = false;
PlexMetadata? _fullMetadata;
@@ -53,6 +55,67 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
bool _watchStateChanged = false;
double _scrollOffset = 0;
// WatchStateAware: watch the show/movie and all season ratingKeys
@override
Set<String>? get watchedRatingKeys {
final keys = <String>{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<void> _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();
+15 -1
View File
@@ -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<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
}
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable {
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable, WatchStateAware {
PlexClient? _client;
@override
@@ -44,6 +46,18 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
// Capture keyboard mode once at init to avoid rebuild dependency
bool _initialKeyboardMode = false;
// WatchStateAware: watch all episode ratingKeys
@override
Set<String>? 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) {
@@ -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);
}
+13 -2
View File
@@ -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<void> markAsWatched(String ratingKey) async {
///
/// If [metadata] is provided, emits a [WatchStateEvent] for UI updates.
Future<void> 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<void> markAsUnwatched(String ratingKey) async {
///
/// If [metadata] is provided, emits a [WatchStateEvent] for UI updates.
Future<void> 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
+144
View File
@@ -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<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;
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<String> 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<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
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<String> _buildParentChain(PlexMetadata metadata) {
final chain = <String>[];
if (metadata.parentRatingKey != null) {
chain.add(metadata.parentRatingKey!);
}
if (metadata.grandparentRatingKey != null) {
chain.add(metadata.grandparentRatingKey!);
}
return chain;
}
void dispose() {
_controller?.close();
_controller = null;
}
}
+10 -4
View File
@@ -289,7 +289,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
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<OfflineWatchProvider>();
await offlineWatch.markAsWatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey);
if (context.mounted) {
@@ -297,13 +297,18 @@ class MediaContextMenuState extends State<MediaContextMenu> {
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<OfflineWatchProvider>();
await offlineWatch.markAsUnwatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey);
if (context.mounted) {
@@ -311,9 +316,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
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,
);
}