fix(ui): refresh stale watch state

This commit is contained in:
edde746
2026-05-09 06:00:36 +02:00
parent 3e6ebcdb6a
commit 7a3f63683e
21 changed files with 572 additions and 195 deletions
+9
View File
@@ -48,6 +48,7 @@ import 'providers/playback_state_provider.dart';
import 'providers/download_provider.dart';
import 'providers/offline_mode_provider.dart';
import 'providers/offline_watch_provider.dart';
import 'providers/watch_state_overlay_provider.dart';
import 'providers/companion_remote_provider.dart';
import 'providers/shader_provider.dart';
import 'utils/snackbar_helper.dart';
@@ -709,6 +710,14 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider;
},
),
ChangeNotifierProxyProvider<ActiveProfileProvider, WatchStateOverlayProvider>(
create: (_) => WatchStateOverlayProvider(),
update: (_, activeProfile, previous) {
final provider = previous ?? WatchStateOverlayProvider();
provider.setActiveProfileId(activeProfile.activeId);
return provider;
},
),
ChangeNotifierProxyProvider<ActiveProfileProvider, OfflineWatchSyncService>(
create: (context) {
final offlineModeProvider = context.read<OfflineModeProvider>();
@@ -0,0 +1,99 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../media/media_item.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
import '../utils/watch_state_notifier.dart';
@immutable
class WatchStateOverlayPatch {
final bool? isWatched;
final bool hasViewOffsetMs;
final int? viewOffsetMs;
const WatchStateOverlayPatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is WatchStateOverlayPatch &&
other.isWatched == isWatched &&
other.hasViewOffsetMs == hasViewOffsetMs &&
other.viewOffsetMs == viewOffsetMs;
@override
int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs);
}
/// Session-local watch-state overlay for immediate UI freshness.
///
/// Server fetches remain the source of truth; this only patches stale
/// [MediaItem] snapshots while a screen waits for its next refresh.
class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
WatchStateOverlayProvider() {
_subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent);
}
StreamSubscription<WatchStateEvent>? _subscription;
final Map<String, WatchStateOverlayPatch> _patches = {};
String? _activeProfileId;
WatchStateOverlayPatch? patchForGlobalKey(String globalKey) => _patches[globalKey];
WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey);
MediaItem apply(MediaItem item) {
return applyPatch(item, patchForItem(item));
}
static MediaItem applyPatch(MediaItem item, WatchStateOverlayPatch? patch) {
if (patch == null) return item;
return item.copyWith(
viewCount: patch.isWatched == null ? null : (patch.isWatched! ? 1 : 0),
viewOffsetMs: patch.hasViewOffsetMs ? patch.viewOffsetMs : null,
);
}
void setActiveProfileId(String? profileId) {
if (_activeProfileId == profileId) return;
_activeProfileId = profileId;
if (_patches.isEmpty) return;
_patches.clear();
safeNotifyListeners();
}
void _onWatchStateEvent(WatchStateEvent event) {
final patch = switch (event.changeType) {
WatchStateChangeType.watched => const WatchStateOverlayPatch(
isWatched: true,
hasViewOffsetMs: true,
viewOffsetMs: 0,
),
WatchStateChangeType.unwatched => const WatchStateOverlayPatch(
isWatched: false,
hasViewOffsetMs: true,
viewOffsetMs: 0,
),
WatchStateChangeType.progressUpdate => WatchStateOverlayPatch(
hasViewOffsetMs: event.viewOffset != null,
viewOffsetMs: event.viewOffset,
),
WatchStateChangeType.removedFromContinueWatching => null,
};
if (patch == null) return;
if (_patches[event.globalKey] == patch) return;
_patches[event.globalKey] = patch;
safeNotifyListeners();
}
@override
void dispose() {
_subscription?.cancel();
_subscription = null;
super.dispose();
}
}
+3
View File
@@ -57,6 +57,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
serverName: widget.serverName,
);
@override
String? get itemServerId => widget.serverId;
@override
String get title => widget.actorName;
@@ -98,13 +98,9 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
@override
void updateItemInLists(String itemId, MediaItem updatedItem) {
if (mounted) {
setState(() {
final index = items.indexWhere((it) => it.id == itemId);
if (index != -1) {
items[index] = updatedItem;
}
});
final index = items.indexWhere((it) => it.id == itemId);
if (index != -1) {
items[index] = updatedItem;
}
}
@@ -38,6 +38,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
@override
MediaItem get mediaItem => widget.collection;
@override
String? get itemServerId => widget.collection.serverId;
@override
String get title => widget.collection.title!;
+13 -1
View File
@@ -164,8 +164,20 @@ class _DiscoverScreenState extends State<DiscoverScreen>
@override
void onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
_removeContinueWatchingItem(event.itemId);
unawaited(_refreshContinueWatching());
return;
}
// Refresh continue watching when any relevant item changes
_refreshContinueWatching();
unawaited(_refreshContinueWatching());
}
void _removeContinueWatchingItem(String itemId) {
setState(() {
_onDeck.removeWhere((item) => item.id == itemId);
});
}
// Track initial load so we can focus hero when content first appears
+25 -7
View File
@@ -256,13 +256,31 @@ class _HubDetailScreenState extends State<HubDetailScreen>
}
}
void _handleItemRefresh(String ratingKey) {
setState(() {
final index = _items.indexWhere((item) => item.id == ratingKey);
if (index != -1) {
appLogger.d('Item refresh requested for: $ratingKey');
}
});
Future<void> _handleItemRefresh(String ratingKey) async {
final itemIndex = _items.indexWhere((item) => item.id == ratingKey);
final filteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey);
final existing = itemIndex != -1
? _items[itemIndex]
: filteredIndex != -1
? _filteredItems[filteredIndex]
: null;
if (existing == null) return;
final serverId = existing.serverId ?? widget.hub.serverId;
if (serverId == null) return;
try {
final updated = await context.tryGetMediaClientForServer(serverId)?.fetchItem(ratingKey);
if (updated == null || !mounted) return;
setState(() {
final currentItemIndex = _items.indexWhere((item) => item.id == ratingKey);
if (currentItemIndex != -1) _items[currentItemIndex] = updated;
final currentFilteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey);
if (currentFilteredIndex != -1) _filteredItems[currentFilteredIndex] = updated;
});
if (_selectedSort != null) _applySort();
} catch (e) {
appLogger.d('Item refresh skipped for: $ratingKey', error: e);
}
}
@override
@@ -46,11 +46,13 @@ import '../../../services/storage_service.dart';
import '../../../services/settings_service.dart';
import '../../../mixins/grid_focus_node_mixin.dart';
import '../../../mixins/item_updatable.dart';
import '../../../mixins/watch_state_aware.dart';
import '../../../mixins/deletion_aware.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../utils/deletion_notifier.dart';
import '../../../utils/global_key_utils.dart';
import '../../../utils/watch_state_notifier.dart';
import '../../../utils/platform_detector.dart';
import '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
@@ -81,7 +83,13 @@ class LibraryBrowseTab extends BaseLibraryTab<MediaItem> {
}
class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrowseTab>
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware, PaginatedItemLoader<LibraryBrowseTab> {
with
ItemUpdatable,
LibraryTabFocusMixin,
GridFocusNodeMixin,
WatchStateAware,
DeletionAware,
PaginatedItemLoader<LibraryBrowseTab> {
@override
String? get itemServerId => widget.library.serverId;
@@ -91,6 +99,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
@override
String? get deletionServerId => widget.library.serverId;
@override
String? get watchStateServerId => widget.library.serverId;
@override
Set<String>? get watchedIds => loadedItems.values.map((e) => e.id).toSet();
@override
Set<String>? get watchedGlobalKeys {
if (loadedItems.isEmpty) return <String>{};
final keys = <String>{};
for (final item in loadedItems.values) {
final serverId = item.serverId ?? widget.library.serverId;
if (serverId == null) return null;
keys.add(_toGlobalKey(item.id, serverId: serverId));
}
return keys;
}
@override
Set<String>? get deletionIds => loadedItems.values.map((e) => e.id).toSet();
@@ -107,6 +134,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return keys;
}
@override
void onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.progressUpdate ||
event.changeType == WatchStateChangeType.removedFromContinueWatching) {
return;
}
final affectedIds = {event.itemId, ...event.parentChain};
for (final item in loadedItems.values) {
if (affectedIds.contains(item.id)) {
unawaited(updateItem(item.id));
}
}
}
@override
void onDeletionEvent(DeletionEvent event) {
// If we have an item that matches the rating key exactly, remove it and rebuild indices
@@ -154,14 +196,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
@override
void updateItemInLists(String itemId, MediaItem updatedMetadata) {
setState(() {
for (final entry in loadedItems.entries) {
if (entry.value.id == itemId) {
loadedItems[entry.key] = updatedMetadata;
break;
}
for (final entry in loadedItems.entries) {
if (entry.value.id == itemId) {
loadedItems[entry.key] = updatedMetadata;
break;
}
});
}
}
// Browse-specific state (not in base class)
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -5,7 +7,10 @@ import '../../../i18n/strings.g.dart';
import '../../../media/media_hub.dart';
import '../../../media/media_item.dart';
import '../../../mixins/item_updatable.dart';
import '../../../mixins/watch_state_aware.dart';
import '../../../utils/global_key_utils.dart';
import '../../../utils/provider_extensions.dart';
import '../../../utils/watch_state_notifier.dart';
import '../../../widgets/hub_section.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
@@ -26,13 +31,45 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
}
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab> with ItemUpdatable {
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
with ItemUpdatable, WatchStateAware {
/// GlobalKeys for each hub section to enable vertical navigation
final List<GlobalKey<HubSectionState>> _hubKeys = [];
@override
String? get itemServerId => widget.library.serverId;
@override
String? get watchStateServerId => widget.library.serverId;
@override
Set<String>? get watchedIds {
final keys = <String>{};
for (final hub in items) {
for (final item in hub.items) {
keys.add(item.id);
if (item.parentId != null) keys.add(item.parentId!);
if (item.grandparentId != null) keys.add(item.grandparentId!);
}
}
return keys;
}
@override
Set<String>? get watchedGlobalKeys {
final keys = <String>{};
for (final hub in items) {
for (final item in hub.items) {
final serverId = item.serverId ?? widget.library.serverId;
if (serverId == null) return null;
keys.add(buildGlobalKey(serverId, item.id));
if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!));
if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!));
}
}
return keys;
}
@override
void updateItemInLists(String itemId, MediaItem updatedItem) {
// Update the item in any hub that contains it. MediaHub items are
@@ -48,6 +85,43 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
}
}
@override
void onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.progressUpdate) return;
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
_removeContinueWatchingItem(event.itemId);
unawaited(loadItems());
return;
}
final affectedIds = {event.itemId, ...event.parentChain};
final refreshIds = <String>{};
for (final hub in items) {
for (final item in hub.items) {
if (affectedIds.contains(item.id)) {
refreshIds.add(item.id);
}
}
}
for (final itemId in refreshIds) {
unawaited(updateItem(itemId));
}
}
void _removeContinueWatchingItem(String itemId) {
setState(() {
for (var i = 0; i < items.length; i++) {
final hub = items[i];
if (!_isContinueWatchingHub(hub)) continue;
final newItems = hub.items.where((item) => item.id != itemId).toList();
if (newItems.length != hub.items.length) {
items[i] = hub.copyWith(items: newItems, size: newItems.length);
}
}
});
}
@override
IconData get emptyIcon => Symbols.recommend_rounded;
@@ -174,8 +174,6 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
context,
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
);
unawaited(_updateWatchStateOffline());
unawaited(_loadOfflineOnDeckEpisode());
}
} else {
// Online mode: dispatch via the right backend's neutral method so
+53 -77
View File
@@ -34,7 +34,6 @@ import '../utils/media_image_helper.dart';
import '../services/plex_client.dart';
import '../media/media_server_client.dart';
import '../services/media_list_playback_launcher.dart';
import '../services/offline_watch_sync_service.dart';
import '../utils/content_utils.dart';
import '../utils/rating_utils.dart';
import '../models/download_models.dart';
@@ -192,41 +191,61 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
void onWatchStateChanged(WatchStateEvent event) {
_watchStateChanged = true;
final epIndex = _episodes.indexWhere((e) => e.id == event.itemId);
if (event.changeType == WatchStateChangeType.progressUpdate && event.viewOffset != null) {
_patchLocalProgress(event.itemId, event.viewOffset!, epIndex: epIndex);
} else {
_localProgressById.remove(event.itemId);
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
return;
}
if (event.changeType == WatchStateChangeType.progressUpdate) {
if (event.viewOffset != null) {
_patchLocalProgress(event.itemId, event.viewOffset!, epIndex: epIndex);
}
if (event.isNowWatched != true) return;
}
_localProgressById.remove(event.itemId);
_patchWatchedStateFromEvent(
event,
epIndex: epIndex,
clearWatchedProgress: !widget.isOffline || event.changeType == WatchStateChangeType.progressUpdate,
);
if (widget.isOffline) {
// Offline: skip network refetch — patch the affected episode (or
// the show metadata) in-memory using the local watch flag the event
// already carries. The sync service drains queued actions to the
// server when the device reconnects.
if (epIndex != -1 && event.isNowWatched != null) {
setStateIfMounted(() {
final updated = _episodes[epIndex].copyWith(
viewCount: event.isNowWatched! ? 1 : 0,
viewOffsetMs: event.isNowWatched! ? _episodes[epIndex].viewOffsetMs : 0,
);
_episodes[epIndex] = updated;
_syncEpisodeToCache(epIndex, updated);
});
} else if (event.itemId == _metadata.id) {
unawaited(_updateWatchStateOffline());
if (_metadata.isShow) {
unawaited(_loadOfflineOnDeckEpisode());
}
return;
}
// Online: re-fetch the affected row so server-derived counters
// (parent leafCounts, lastViewedAt) refresh too.
if (epIndex != -1) {
_updateEpisodeWatchState(event.itemId);
} else {
_refreshWatchState();
}
// Online: refresh server-derived counters and on-deck state. A watched
// episode can change the hero play target even when the episode row itself
// was already visible and patched locally.
unawaited(_refreshWatchState());
}
void _patchWatchedStateFromEvent(WatchStateEvent event, {required int epIndex, required bool clearWatchedProgress}) {
final isWatched = event.isNowWatched;
if (isWatched == null) return;
final viewOffsetMs = isWatched && !clearWatchedProgress ? null : 0;
setStateIfMounted(() {
final base = _fullMetadata ?? widget.metadata;
if (base.id == event.itemId) {
_fullMetadata = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
}
final onDeckEpisode = _onDeckEpisode;
if (onDeckEpisode != null && onDeckEpisode.id == event.itemId) {
_onDeckEpisode = onDeckEpisode.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
}
if (epIndex != -1) {
final updated = _episodes[epIndex].copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
_episodes[epIndex] = updated;
_syncEpisodeToCache(epIndex, updated);
}
});
}
void _patchLocalProgress(String itemId, int viewOffset, {int? epIndex}) {
@@ -377,9 +396,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (metadata != null) {
setStateIfMounted(() {
_fullMetadata = _applyLocalProgress(metadata.copyWith(serverId: serverId, serverName: serverName));
if (onDeckEpisode != null) {
_onDeckEpisode = _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName));
}
_onDeckEpisode = onDeckEpisode == null
? null
: _applyLocalProgress(onDeckEpisode.copyWith(serverId: serverId, serverName: serverName));
});
}
@@ -403,30 +422,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
}
/// Update a single episode's watch state without refetching everything.
/// Backend-neutral so Jellyfin items refresh in place when their
/// watched flag changes (the previous Plex-only path no-op'd for
/// Jellyfin and left the row stale).
Future<void> _updateEpisodeWatchState(String ratingKey) async {
final mediaClient = _getMediaClientForMetadata(context);
if (mediaClient == null) return;
try {
final refreshed = await mediaClient.fetchItem(ratingKey);
if (refreshed != null) {
setStateIfMounted(() {
final i = _episodes.indexWhere((e) => e.id == ratingKey);
if (i != -1) {
final updated = _applyLocalProgress(refreshed);
_episodes[i] = updated;
_syncEpisodeToCache(i, updated);
}
});
}
} catch (e) {
appLogger.d('Episode cache sync skipped', error: e);
}
}
@override
void initState() {
super.initState();
@@ -2009,34 +2004,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final offlineWatchProvider = context.read<OfflineWatchProvider>();
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id);
setStateIfMounted(() {
_onDeckEpisode = nextEpisode == null ? null : _applyLocalProgress(nextEpisode);
});
if (nextEpisode != null) {
setStateIfMounted(() {
_onDeckEpisode = _applyLocalProgress(nextEpisode);
});
appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}');
}
}
/// Offline: patch the in-memory metadata so the UI reflects a queued
/// watch/unwatch action immediately. The sync service holds the truth
/// for offline state and will reconcile with the server on reconnect,
/// so we don't need to round-trip through the per-backend cache here.
Future<void> _updateWatchStateOffline() async {
final serverId = _metadata.serverId;
if (serverId == null) return;
final localStatus = await context.read<OfflineWatchSyncService>().getLocalWatchStatus('$serverId:${_metadata.id}');
if (localStatus == null) return;
setStateIfMounted(() {
final base = _fullMetadata ?? _metadata;
_fullMetadata = base.copyWith(
viewCount: localStatus ? 1 : 0,
// Reset the resume position when transitioning to unwatched, mirroring
// the previous Plex cache-mutation behavior.
viewOffsetMs: localStatus ? base.viewOffsetMs : 0,
);
});
}
Future<void> _playFirstEpisode() async {
try {
// If seasons aren't loaded yet, wait for them or load them
@@ -43,6 +43,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
@override
Object get mediaItem => widget.playlist;
@override
String? get itemServerId => widget.playlist.serverId;
@override
String get title => widget.playlist.title;
+34 -20
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../mixins/context_menu_tap_mixin.dart';
import '../../providers/watch_state_overlay_provider.dart';
import '../../utils/formatters.dart';
import '../../utils/provider_extensions.dart';
import '../../i18n/strings.g.dart';
@@ -44,8 +46,20 @@ class PlaylistItemCard extends StatefulWidget {
}
class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
MediaItem _effectiveItem(BuildContext context) {
try {
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
(provider) => provider.patchForGlobalKey(widget.item.globalKey),
);
return WatchStateOverlayProvider.applyPatch(widget.item, patch);
} on ProviderNotFoundException {
return widget.item;
}
}
@override
Widget build(BuildContext context) {
final item = _effectiveItem(context);
final colorScheme = Theme.of(context).colorScheme;
// Determine if row is focused (main content area)
@@ -71,7 +85,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
return MediaContextMenu(
key: contextMenuKey,
item: widget.item,
item: item,
onRefresh: widget.onRefresh,
onTap: widget.onTap,
child: Card(
@@ -118,7 +132,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
),
// Poster thumbnail
_buildPosterImage(context),
_buildPosterImage(context, item),
const SizedBox(width: 12),
@@ -130,7 +144,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
children: [
// Title
Text(
widget.item.displayTitle,
item.displayTitle,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -140,19 +154,19 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
// Subtitle (episode info or type)
Text(
_buildSubtitle(),
_buildSubtitle(item),
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Progress indicator if partially watched
if (widget.item.viewOffsetMs != null && widget.item.durationMs != null)
if (item.viewOffsetMs != null && item.durationMs != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: MediaProgressBar(
viewOffset: widget.item.viewOffsetMs!,
duration: widget.item.durationMs!,
viewOffset: item.viewOffsetMs!,
duration: item.durationMs!,
minHeight: 3,
),
),
@@ -163,9 +177,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
const SizedBox(width: 12),
// Duration
if (widget.item.durationMs != null)
if (item.durationMs != null)
Text(
formatDurationTextual(widget.item.durationMs!),
formatDurationTextual(item.durationMs!),
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
@@ -194,14 +208,14 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
);
}
Widget _buildPosterImage(BuildContext context) {
final posterUrl = widget.item.posterThumb();
Widget _buildPosterImage(BuildContext context, MediaItem item) {
final posterUrl = item.posterThumb();
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: OptimizedMediaImage.poster(
// Backend-neutral lookup so Jellyfin items render via their own
// image transcoder; null falls through to the placeholder below.
client: context.tryGetMediaClientWithFallback(widget.item.serverId),
client: context.tryGetMediaClientWithFallback(item.serverId),
imagePath: posterUrl,
width: 60,
height: 90,
@@ -221,21 +235,21 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
);
}
String _buildSubtitle() {
final kind = widget.item.kind;
String _buildSubtitle(MediaItem item) {
final kind = item.kind;
if (kind == MediaKind.episode) {
// For episodes, show "S#E# - Episode Title"
final season = widget.item.parentIndex;
final episode = widget.item.index;
final season = item.parentIndex;
final episode = item.index;
if (season != null && episode != null) {
return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}';
return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}';
}
return widget.item.displaySubtitle ?? t.discover.tvShow;
return item.displaySubtitle ?? t.discover.tvShow;
} else if (kind == MediaKind.movie) {
// For movies, show year and edition (edition is Plex-only; null elsewhere)
final year = widget.item.year?.toString();
final edition = widget.item.editionTitle;
final year = item.year?.toString();
final edition = item.editionTitle;
if (year != null && edition != null) {
return '$year · $edition';
}
+1
View File
@@ -180,6 +180,7 @@ class _SearchScreenState extends State<SearchScreen>
forceListMode: true,
disableScale: true,
focusNode: index == 0 ? _firstResultFocusNode : null,
onRefresh: updateItem,
onListRefresh: () => updateItem(item.id),
onNavigateLeft: _navigateToSidebar,
onNavigateUp: index == 0 ? focusSearchInput : null,
+36 -16
View File
@@ -69,6 +69,10 @@ class PlaybackProgressTracker {
/// Whether the final stopped progress event was already emitted locally.
bool _stopProgressNotified = false;
Duration? _lastProgressNotifiedPosition;
static const Duration _progressNotifyDelta = Duration(seconds: 30);
final PlaybackReportSession? _reportSession;
PlaybackProgressTracker({
@@ -165,16 +169,23 @@ class PlaybackProgressTracker {
if (isOffline) {
// Queue progress update for later sync
await _sendOfflineProgress(position, duration);
_notifyProgressIfNeeded(position, duration, force: state == 'stopped');
} else if (state == 'stopped') {
// Stopped must complete before disposal
await _sendOnlineProgress(state, position, duration);
final accepted = await _sendOnlineProgress(state, position, duration);
_resetBackoff();
if (accepted) {
_notifyProgressIfNeeded(position, duration, force: true);
}
} else {
// Fire-and-forget for playing/paused — avoid blocking the Dart event loop
unawaited(
_sendOnlineProgress(state, position, duration)
.then((_) {
.then((accepted) {
_resetBackoff();
if (accepted) {
_notifyProgressIfNeeded(position, duration);
}
})
.catchError((Object e) {
_consecutiveFailures++;
@@ -188,18 +199,6 @@ class PlaybackProgressTracker {
}),
);
}
// Emit watch state event on stop for UI updates across screens.
// Skip if already scrobbled — markWatched already emitted a watched event.
if (state == 'stopped' && position.inMilliseconds > 0 && !_scrobbled && !_stopProgressNotified) {
_stopProgressNotified = true;
WatchStateNotifier().notifyProgress(
item: metadata,
viewOffset: position.inMilliseconds,
duration: duration.inMilliseconds,
watchedThreshold: client?.watchedThreshold ?? 0.9,
);
}
} catch (e) {
if (!isOffline) {
_consecutiveFailures++;
@@ -222,12 +221,32 @@ class PlaybackProgressTracker {
}
}
void _notifyProgressIfNeeded(Duration position, Duration duration, {bool force = false}) {
if (_scrobbled) return;
if (position.inMilliseconds <= 0 || duration.inMilliseconds <= 0) return;
if (force) {
if (_stopProgressNotified) return;
_stopProgressNotified = true;
} else {
final last = _lastProgressNotifiedPosition;
if (last != null && (position - last).abs() < _progressNotifyDelta) return;
}
_lastProgressNotifiedPosition = position;
WatchStateNotifier().notifyProgress(
item: metadata,
viewOffset: position.inMilliseconds,
duration: duration.inMilliseconds,
watchedThreshold: client?.watchedThreshold ?? 0.9,
);
}
/// Send progress update to the active server through the unified
/// [MediaServerClient.reportPlayback*] surface.
Future<void> _sendOnlineProgress(String state, Duration position, Duration duration) async {
Future<bool> _sendOnlineProgress(String state, Duration position, Duration duration) async {
final c = client;
final session = _reportSession;
if (c == null || session == null) return;
if (c == null || session == null) return false;
final accepted = await session.report(
PlaybackReportSnapshot(
@@ -243,6 +262,7 @@ class PlaybackProgressTracker {
if (accepted) {
await _maybeScrobble(c, position, duration);
}
return accepted;
}
PlaybackStreamSelection _currentStreamSelectionForStopped() {
+4 -1
View File
@@ -3068,7 +3068,10 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
Future<void> markUnwatched(MediaItem item) => markAsUnwatched(item.id, item: item);
@override
Future<void> removeFromContinueWatching(MediaItem item) => removeFromOnDeck(item.id);
Future<void> removeFromContinueWatching(MediaItem item) async {
await removeFromOnDeck(item.id);
WatchStateNotifier().notifyRemovedFromContinueWatching(item: item);
}
/// Rate a media item (0.0-10.0 scale, where each integer = half a star).
/// Pass `-1` to clear an existing rating. Throws [MediaServerHttpException]
+1 -1
View File
@@ -109,7 +109,7 @@ class TraktSyncService {
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
if (!_canPush) return;
if (event.changeType == WatchStateChangeType.progressUpdate) return;
if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) return;
final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType);
if (kind == null) return;
+15 -1
View File
@@ -4,7 +4,7 @@ import 'base_notifier.dart';
import 'global_key_utils.dart';
import 'hierarchical_event_mixin.dart';
enum WatchStateChangeType { watched, unwatched, progressUpdate }
enum WatchStateChangeType { watched, unwatched, progressUpdate, removedFromContinueWatching }
/// Event representing a watch state change with parent chain for hierarchical invalidation
class WatchStateEvent with HierarchicalEventMixin {
@@ -132,4 +132,18 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
),
);
}
/// Helper to emit a Continue Watching removal event.
void notifyRemovedFromContinueWatching({required MediaItem item}) {
notify(
WatchStateEvent(
itemId: item.id,
serverId: item.serverId ?? '',
changeType: WatchStateChangeType.removedFromContinueWatching,
parentChain: item.parentChain,
mediaType: item.kind.id,
librarySectionID: item.libraryId,
),
);
}
}
+44 -36
View File
@@ -8,6 +8,7 @@ import '../focus/focusable_wrapper.dart';
import '../mixins/context_menu_tap_mixin.dart';
import '../models/download_models.dart';
import '../providers/download_provider.dart';
import '../providers/watch_state_overlay_provider.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
@@ -56,7 +57,18 @@ class EpisodeCard extends StatefulWidget {
}
class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<EpisodeCard> {
Widget _buildEpisodeMetaRow(BuildContext context) {
MediaItem _effectiveEpisode(BuildContext context) {
try {
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
(provider) => provider.patchForGlobalKey(widget.episode.globalKey),
);
return WatchStateOverlayProvider.applyPatch(widget.episode, patch);
} on ProviderNotFoundException {
return widget.episode;
}
}
Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode) {
final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12);
final dot = Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
@@ -64,13 +76,13 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
);
return Row(
children: [
if (widget.episode.durationMs != null)
Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.durationMs!)), style: mutedStyle),
if (widget.episode.originallyAvailableAt != null) ...[
if (episode.durationMs != null)
Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle),
if (episode.originallyAvailableAt != null) ...[
dot,
Text(formatFullDate(widget.episode.originallyAvailableAt!), style: mutedStyle),
Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle),
],
if (widget.episode.userRating != null && widget.episode.userRating! > 0) ...[
if (episode.userRating != null && episode.userRating! > 0) ...[
dot,
const Padding(
padding: EdgeInsets.only(top: 2),
@@ -78,9 +90,9 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
),
const SizedBox(width: 2),
Text(
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
? '${(widget.episode.userRating! / 2).toInt()}'
: formatRating(widget.episode.userRating! / 2),
(episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble()
? '${(episode.userRating! / 2).toInt()}'
: formatRating(episode.userRating! / 2),
style: mutedStyle,
),
],
@@ -97,17 +109,15 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
}
Widget _buildContent(BuildContext context, {required bool hideSpoilers}) {
final shouldBlur = hideSpoilers && widget.episode.shouldHideSpoiler;
final episode = _effectiveEpisode(context);
final shouldBlur = hideSpoilers && episode.shouldHideSpoiler;
// Hide progress when offline (not tracked)
final hasProgress =
!widget.isOffline &&
widget.episode.viewOffsetMs != null &&
widget.episode.durationMs != null &&
widget.episode.viewOffsetMs! > 0;
final progress = hasProgress ? widget.episode.viewOffsetMs! / widget.episode.durationMs! : 0.0;
!widget.isOffline && episode.viewOffsetMs != null && episode.durationMs != null && episode.viewOffsetMs! > 0;
final progress = hasProgress ? episode.viewOffsetMs! / episode.durationMs! : 0.0;
final hasActiveProgress = hasProgress && widget.episode.viewOffsetMs! < widget.episode.durationMs!;
final hasActiveProgress = hasProgress && episode.viewOffsetMs! < episode.durationMs!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
@@ -121,12 +131,12 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
disableScale: true,
child: MediaContextMenu(
key: contextMenuKey,
item: widget.episode,
item: episode,
onRefresh: widget.onRefresh,
onListRefresh: widget.onListRefresh,
onTap: widget.onTap,
child: InkWell(
key: Key(widget.episode.id),
key: Key(episode.id),
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
onTap: widget.onTap,
canRequestFocus: false,
@@ -156,10 +166,10 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
? ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: _buildEpisodeThumbnail(),
child: _buildEpisodeThumbnail(episode),
),
)
: _buildEpisodeThumbnail(),
: _buildEpisodeThumbnail(episode),
),
),
@@ -209,7 +219,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
),
),
if (widget.episode.isWatched && !hasActiveProgress)
if (episode.isWatched && !hasActiveProgress)
Positioned(
top: 4,
right: 4,
@@ -234,15 +244,13 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Selector<DownloadProvider, _DownloadSlice>(
selector: (_, p) => _DownloadSlice.from(
p.getProgress(widget.episode.globalKey),
p.isQueueing(widget.episode.globalKey),
),
selector: (_, p) =>
_DownloadSlice.from(p.getProgress(episode.globalKey), p.isQueueing(episode.globalKey)),
builder: (context, slice, _) {
Widget? downloadStatusIcon;
// Only show download status in online mode
if (!widget.isOffline && widget.episode.serverId != null) {
if (!widget.isOffline && episode.serverId != null) {
final status = slice.status;
final mutedBase = tokens(context).textMuted;
@@ -263,7 +271,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
return Row(
children: [
if (widget.episode.index != null)
if (episode.index != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
@@ -271,7 +279,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
borderRadius: const BorderRadius.all(Radius.circular(3)),
),
child: Text(
'E${widget.episode.index}',
'E${episode.index}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 11,
@@ -283,7 +291,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
const SizedBox(width: 8),
Expanded(
child: Text(
widget.episode.title!,
episode.title!,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
@@ -296,11 +304,11 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
},
),
if (!shouldBlur && widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[
if (!shouldBlur && episode.summary != null && episode.summary!.isNotEmpty) ...[
const SizedBox(height: 6),
if (PlatformDetector.isTV())
Text(
widget.episode.summary!,
episode.summary!,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
@@ -309,7 +317,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
)
else
CollapsibleText(
text: widget.episode.summary!,
text: episode.summary!,
maxLines: 3,
small: true,
style: Theme.of(
@@ -319,7 +327,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
],
const SizedBox(height: 8),
_buildEpisodeMetaRow(context),
_buildEpisodeMetaRow(context, episode),
],
),
),
@@ -332,7 +340,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
);
}
Widget _buildEpisodeThumbnail() {
Widget _buildEpisodeThumbnail(MediaItem episode) {
if (widget.isOffline && widget.localPosterPath != null) {
return OptimizedMediaImage.thumb(
client: null,
@@ -343,10 +351,10 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
);
}
if (widget.episode.thumbPath != null) {
if (episode.thumbPath != null) {
return OptimizedMediaImage.thumb(
client: widget.client,
imagePath: widget.episode.thumbPath,
imagePath: episode.thumbPath,
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
placeholder: (context, url) => const PlaceholderContainer(),
+38 -17
View File
@@ -11,6 +11,7 @@ import '../media/media_kind.dart';
import '../media/media_playlist.dart';
import '../mixins/context_menu_tap_mixin.dart';
import '../providers/download_provider.dart';
import '../providers/watch_state_overlay_provider.dart';
import '../services/download_storage_service.dart';
import '../services/settings_service.dart';
import 'settings_builder.dart';
@@ -82,12 +83,33 @@ class MediaCard extends StatefulWidget {
class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard> {
/// Public method to trigger tap action (for keyboard/gamepad SELECT)
void handleTap() {
_handleTap(context);
_handleTap(context, _effectiveItemForAction(context));
}
String _buildSemanticLabel() {
Object _effectiveItem(BuildContext context) {
final item = widget.item;
if (item is! MediaItem) return item;
try {
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
(provider) => provider.patchForGlobalKey(item.globalKey),
);
return WatchStateOverlayProvider.applyPatch(item, patch);
} on ProviderNotFoundException {
return item;
}
}
Object _effectiveItemForAction(BuildContext context) {
final item = widget.item;
if (item is! MediaItem) return item;
try {
return context.read<WatchStateOverlayProvider>().apply(item);
} on ProviderNotFoundException {
return item;
}
}
String _buildSemanticLabel(Object item) {
// Playlists don't expose kind, so build a simple localized label and exit early
if (item is MediaPlaylist) {
final count = item.leafCount;
@@ -132,7 +154,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
return baseLabel;
}
void _handleTap(BuildContext context) async {
void _handleTap(BuildContext context, Object item) async {
// Ignore taps while context menu is open to avoid double-activating
if (contextMenuKey.currentState?.isContextMenuOpen == true) {
return;
@@ -140,7 +162,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
final result = await navigateToMediaItem(
context,
widget.item,
item,
onRefresh: widget.onRefresh,
isOffline: widget.isOffline,
playDirectly: widget.isInContinueWatching,
@@ -161,11 +183,10 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
}
/// Get the local poster path for offline mode
String? _getLocalPosterPath(BuildContext context) {
String? _getLocalPosterPath(BuildContext context, Object item) {
if (!widget.isOffline) return null;
if (widget.item is! MediaItem) return null;
if (item is! MediaItem) return null;
final item = widget.item as MediaItem;
if (item.serverId == null) return null;
final downloadProvider = context.read<DownloadProvider>();
@@ -192,6 +213,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
}
Widget _buildContent(BuildContext context) {
final item = _effectiveItem(context);
final ViewMode viewMode;
if (widget.forceListMode) {
viewMode = ViewMode.list;
@@ -201,15 +223,15 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
viewMode = SettingsService.instanceOrNull!.read(SettingsService.viewMode);
}
final semanticLabel = _buildSemanticLabel();
final localPosterPath = _getLocalPosterPath(context);
final semanticLabel = _buildSemanticLabel(item);
final localPosterPath = _getLocalPosterPath(context, item);
final cardWidget = viewMode == ViewMode.grid
? _buildGridCard(context, semanticLabel, localPosterPath)
? _buildGridCard(context, item, semanticLabel, localPosterPath)
: _MediaCardList(
item: widget.item,
item: item,
semanticLabel: semanticLabel,
onTap: () => _handleTap(context),
onTap: () => _handleTap(context, item),
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
@@ -224,11 +246,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
// programmatic context menu access; gesture callbacks are on InkWell directly.
return MediaContextMenu(
key: contextMenuKey,
item: widget.item,
item: item,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
onListRefresh: widget.onListRefresh,
onTap: () => _handleTap(context),
onTap: () => _handleTap(context, item),
isInContinueWatching: widget.isInContinueWatching,
collectionId: widget.collectionId,
child: cardWidget,
@@ -237,8 +259,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
/// flattened Column. Semantics removed (InkWell provides button semantics).
Widget _buildGridCard(BuildContext context, String semanticLabel, String? localPosterPath) {
final item = widget.item;
Widget _buildGridCard(BuildContext context, Object item, String semanticLabel, String? localPosterPath) {
// Compute actual poster dimensions from card dimensions
final posterWidth = widget.width != null ? widget.width! - 6 : null; // 3px padding each side
final posterHeight = widget.height;
@@ -247,7 +268,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
width: widget.width,
child: InkWell(
canRequestFocus: false,
onTap: () => _handleTap(context),
onTap: () => _handleTap(context, item),
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
@@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/watch_state_overlay_provider.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
MediaItem _item({String id = '1', int? viewOffsetMs, int? viewCount = 0}) {
return MediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'server',
durationMs: 100000,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
);
}
Future<void> _drainEvents() => Future<void>.delayed(Duration.zero);
void main() {
group('WatchStateOverlayProvider', () {
test('applies watched patches immediately', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
final item = _item(viewOffsetMs: 40000);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
await _drainEvents();
final patched = provider.apply(item);
expect(patched.isWatched, isTrue);
expect(patched.viewOffsetMs, 0);
});
test('applies progress patches without changing watched state', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
final item = _item(viewCount: 1);
WatchStateNotifier().notifyProgress(item: item, viewOffset: 30000, duration: 100000);
await _drainEvents();
final patched = provider.apply(item);
expect(patched.isWatched, isTrue);
expect(patched.viewOffsetMs, 30000);
});
test('clears patches when active profile changes', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
final item = _item();
provider.setActiveProfileId('a');
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
await _drainEvents();
expect(provider.apply(item).isWatched, isTrue);
provider.setActiveProfileId('b');
expect(provider.apply(item).isWatched, isFalse);
});
});
}