refactor(watch): promote overlay to hierarchy-aware WatchStateStore

Resolution now considers parentChain ancestors (newest event wins) and
watched patches set container leaf counts, so container marks reach
descendant cards and vice versa.
This commit is contained in:
edde746
2026-06-10 05:18:02 +02:00
parent cd9498abb8
commit 516925cf50
10 changed files with 253 additions and 106 deletions
+4 -4
View File
@@ -51,7 +51,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/watch_state_store.dart';
import 'providers/companion_remote_provider.dart';
import 'providers/shader_provider.dart';
import 'utils/snackbar_helper.dart';
@@ -775,10 +775,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider;
},
),
ChangeNotifierProxyProvider2<ActiveProfileProvider, MultiServerProvider, WatchStateOverlayProvider>(
create: (_) => WatchStateOverlayProvider(),
ChangeNotifierProxyProvider2<ActiveProfileProvider, MultiServerProvider, WatchStateStore>(
create: (_) => WatchStateStore(),
update: (_, activeProfile, multiServer, previous) {
final provider = previous ?? WatchStateOverlayProvider();
final provider = previous ?? WatchStateStore();
provider.setActiveProfileId(activeProfile.activeId);
provider.setActiveClientScopesByServer({
for (final serverId in multiServer.serverManager.serverIds)
+10
View File
@@ -389,6 +389,16 @@ sealed class MediaItem with _$MediaItem {
return viewCount != null && viewCount! > 0;
}
/// Copy with the watched flag applied so [isWatched] reflects it for every
/// kind: containers need their leaf counts patched, not just [viewCount].
MediaItem withWatchedFlag(bool isWatched) {
var updated = copyWith(viewCount: isWatched ? 1 : 0);
if (leafCount != null || viewedLeafCount != null) {
updated = updated.copyWith(viewedLeafCount: isWatched ? (leafCount ?? viewedLeafCount ?? 1) : 0);
}
return updated;
}
/// Display-friendly title that prefers the show name for episodes/seasons.
String get displayTitle {
if ((kind == MediaKind.episode || kind == MediaKind.season) && grandparentTitle != null) {
@@ -10,14 +10,14 @@ import '../utils/global_key_utils.dart';
import '../utils/watch_state_notifier.dart';
@immutable
class WatchStateOverlayPatch {
class WatchStatePatch {
final bool? isWatched;
final bool hasViewOffsetMs;
final int? viewOffsetMs;
const WatchStateOverlayPatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
factory WatchStateOverlayPatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStateOverlayPatch(
factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch(
isWatched: snapshot.isWatched,
hasViewOffsetMs: snapshot.hasViewOffsetMs,
viewOffsetMs: snapshot.viewOffsetMs,
@@ -26,7 +26,7 @@ class WatchStateOverlayPatch {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is WatchStateOverlayPatch &&
other is WatchStatePatch &&
other.isWatched == isWatched &&
other.hasViewOffsetMs == hasViewOffsetMs &&
other.viewOffsetMs == viewOffsetMs;
@@ -35,30 +35,35 @@ class WatchStateOverlayPatch {
int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs);
}
class _WatchStateOverlayEntry {
final WatchStateOverlayPatch patch;
class _WatchStatePatchEntry {
final WatchStatePatch patch;
final int sequence;
const _WatchStateOverlayEntry(this.patch, this.sequence);
const _WatchStatePatchEntry(this.patch, this.sequence);
}
/// Session-local watch-state overlay for immediate UI freshness.
/// The single session-local layer for watch-state 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() {
/// Server fetches remain the source of truth; [MediaItem] snapshots are never
/// hand-mutated to reflect watch events. Instead, every watch event lands here
/// as a patch, and consumers resolve items at point of use ([apply] /
/// [patchForItem]). Resolution is hierarchy-aware: an item's effective patch
/// is the newest among its own and its [MediaItem.parentChain] ancestors', so
/// marking a show/season reaches every descendant, while a later per-item
/// event still overrides an older container mark.
class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin {
WatchStateStore() {
_subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent);
}
StreamSubscription<WatchStateEvent>? _subscription;
final Map<String, _WatchStateOverlayEntry> _patches = {};
final Map<String, _WatchStatePatchEntry> _patches = {};
String? _activeProfileId;
Map<String, String?> _activeClientScopesByServer = const {};
int _sequence = 0;
WatchStateOverlayPatch? patchForGlobalKey(String globalKey) {
_WatchStateOverlayEntry? scopedEntry;
_WatchStatePatchEntry? _entryFor(String globalKey) {
_WatchStatePatchEntry? scopedEntry;
final parsed = parseGlobalKey(globalKey);
if (parsed != null) {
final scoped = _activeClientScopesByServer[parsed.serverId];
@@ -67,18 +72,36 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti
}
}
final unscopedEntry = _patches[globalKey];
if (scopedEntry == null) return unscopedEntry?.patch;
if (unscopedEntry == null) return scopedEntry.patch;
return scopedEntry.sequence >= unscopedEntry.sequence ? scopedEntry.patch : unscopedEntry.patch;
if (scopedEntry == null) return unscopedEntry;
if (unscopedEntry == null) return scopedEntry;
return scopedEntry.sequence >= unscopedEntry.sequence ? scopedEntry : unscopedEntry;
}
WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey);
WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
WatchStatePatch? patchForItem(MediaItem item) {
var best = _entryFor(item.globalKey);
if (item.parentChain.isNotEmpty) {
final serverId = serverIdOrNull(item.serverId);
for (final parentId in item.parentChain) {
// Mirror MediaItem.globalKey's bare-id fallback when serverId is missing.
final entry = _entryFor(serverId != null ? buildGlobalKey(serverId, parentId) : parentId);
if (entry != null && (best == null || entry.sequence > best.sequence)) best = entry;
}
}
return best?.patch;
}
MediaItem apply(MediaItem item) {
return applyPatch(item, patchForItem(item));
}
static MediaItem applyPatch(MediaItem item, WatchStateOverlayPatch? patch) {
List<MediaItem> applyAll(List<MediaItem> items) {
if (_patches.isEmpty) return items;
return [for (final item in items) apply(item)];
}
static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) {
if (patch == null) return item;
return WatchStateSnapshot(
isWatched: patch.isWatched,
@@ -108,13 +131,13 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti
void _onWatchStateEvent(WatchStateEvent event) {
final snapshot = WatchStateResolver.fromEvent(event);
if (snapshot.isEmpty) return;
final patch = WatchStateOverlayPatch.fromSnapshot(snapshot);
final patch = WatchStatePatch.fromSnapshot(snapshot);
final cacheServerId = event.cacheServerId;
final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId
? buildGlobalKey(ServerId(cacheServerId), event.itemId)
: event.globalKey;
_patches[key] = _WatchStateOverlayEntry(patch, ++_sequence);
_patches[key] = _WatchStatePatchEntry(patch, ++_sequence);
safeNotifyListeners();
}
+3 -3
View File
@@ -6,7 +6,7 @@ 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 '../../providers/watch_state_store.dart';
import '../../utils/formatters.dart';
import '../../utils/provider_extensions.dart';
import '../../i18n/strings.g.dart';
@@ -49,10 +49,10 @@ class PlaylistItemCard extends StatefulWidget {
class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
MediaItem _effectiveItem(BuildContext context) {
try {
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
final patch = context.select<WatchStateStore, WatchStatePatch?>(
(provider) => provider.patchForGlobalKey(widget.item.globalKey),
);
return WatchStateOverlayProvider.applyPatch(widget.item, patch);
return WatchStateStore.applyPatch(widget.item, patch);
} on ProviderNotFoundException {
return widget.item;
}
+1 -1
View File
@@ -14,7 +14,7 @@ class WatchStateSnapshot {
MediaItem apply(MediaItem item) {
var updated = item;
if (isWatched != null) {
updated = updated.copyWith(viewCount: isWatched! ? 1 : 0);
updated = updated.withWatchedFlag(isWatched!);
}
if (hasViewOffsetMs) {
updated = updated.copyWith(viewOffsetMs: viewOffsetMs);
+3 -3
View File
@@ -8,7 +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 '../providers/watch_state_store.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
@@ -60,10 +60,10 @@ class EpisodeCard extends StatefulWidget {
class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<EpisodeCard> {
MediaItem _effectiveEpisode(BuildContext context) {
try {
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
final patch = context.select<WatchStateStore, WatchStatePatch?>(
(provider) => provider.patchForGlobalKey(widget.episode.globalKey),
);
return WatchStateOverlayProvider.applyPatch(widget.episode, patch);
return WatchStateStore.applyPatch(widget.episode, patch);
} on ProviderNotFoundException {
return widget.episode;
}
+4 -4
View File
@@ -13,7 +13,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 '../providers/watch_state_store.dart';
import '../services/download_storage_service.dart';
import '../services/settings_service.dart';
import 'settings_builder.dart';
@@ -97,10 +97,10 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
final item = widget.item;
if (item is! MediaItem) return item;
try {
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
final patch = context.select<WatchStateStore, WatchStatePatch?>(
(provider) => provider.patchForGlobalKey(item.globalKey),
);
return WatchStateOverlayProvider.applyPatch(item, patch);
return WatchStateStore.applyPatch(item, patch);
} on ProviderNotFoundException {
return item;
}
@@ -110,7 +110,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
final item = widget.item;
if (item is! MediaItem) return item;
try {
return context.read<WatchStateOverlayProvider>().apply(item);
return context.read<WatchStateStore>().apply(item);
} on ProviderNotFoundException {
return item;
}
@@ -1,69 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/providers/watch_state_overlay_provider.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
Future<void> _emit(WatchStateEvent event) async {
WatchStateNotifier().notify(event);
await Future<void>.delayed(Duration.zero);
}
WatchStateEvent _event({
required WatchStateChangeType changeType,
required bool? isNowWatched,
String serverId = 'jf-machine',
String itemId = 'item-1',
String? cacheServerId,
int? viewOffset,
}) {
return WatchStateEvent(
itemId: itemId,
serverId: ServerId(serverId),
cacheServerId: cacheServerId,
changeType: changeType,
parentChain: const [],
mediaType: 'movie',
isNowWatched: isNowWatched,
viewOffset: viewOffset,
);
}
void main() {
test('removed from continue watching does not replace an existing watched patch', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true));
await _emit(_event(changeType: WatchStateChangeType.removedFromContinueWatching, isNowWatched: null));
final patch = provider.patchForGlobalKey('jf-machine:item-1');
expect(patch?.isWatched, isTrue);
expect(patch?.viewOffsetMs, 0);
});
test('newer unscoped patch wins over older active scoped patch', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'});
await _emit(
_event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'),
);
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false));
expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isFalse);
});
test('newer active scoped patch wins over older unscoped patch', () async {
final provider = WatchStateOverlayProvider();
addTearDown(provider.dispose);
provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'});
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false));
await _emit(
_event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'),
);
expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue);
});
}
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.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_store.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
Future<void> _emit(WatchStateEvent event) async {
WatchStateNotifier().notify(event);
await Future<void>.delayed(Duration.zero);
}
WatchStateEvent _event({
required WatchStateChangeType changeType,
required bool? isNowWatched,
String serverId = 'jf-machine',
String itemId = 'item-1',
String? cacheServerId,
int? viewOffset,
List<String> parentChain = const [],
String mediaType = 'movie',
}) {
return WatchStateEvent(
itemId: itemId,
serverId: ServerId(serverId),
cacheServerId: cacheServerId,
changeType: changeType,
parentChain: parentChain,
mediaType: mediaType,
isNowWatched: isNowWatched,
viewOffset: viewOffset,
);
}
final _episode = MediaItem(
id: 'episode-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
parentId: 'season-1',
grandparentId: 'show-1',
serverId: 'jf-machine',
);
void main() {
test('removed from continue watching does not replace an existing watched patch', () async {
final provider = WatchStateStore();
addTearDown(provider.dispose);
await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true));
await _emit(_event(changeType: WatchStateChangeType.removedFromContinueWatching, isNowWatched: null));
final patch = provider.patchForGlobalKey('jf-machine:item-1');
expect(patch?.isWatched, isTrue);
expect(patch?.viewOffsetMs, 0);
});
test('newer unscoped patch wins over older active scoped patch', () async {
final provider = WatchStateStore();
addTearDown(provider.dispose);
provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'});
await _emit(
_event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'),
);
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false));
expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isFalse);
});
test('newer active scoped patch wins over older unscoped patch', () async {
final provider = WatchStateStore();
addTearDown(provider.dispose);
provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'});
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false));
await _emit(
_event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'),
);
expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue);
});
test('an ancestor patch reaches descendants through parentChain', () async {
final store = WatchStateStore();
addTearDown(store.dispose);
await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'show-1'));
expect(store.patchForItem(_episode)?.isWatched, isTrue);
expect(store.apply(_episode).isWatched, isTrue);
// The episode's own key still has no patch — only resolution sees the ancestor.
expect(store.patchForGlobalKey(_episode.globalKey), isNull);
});
test('newer container mark overrides an older per-item patch', () async {
final store = WatchStateStore();
addTearDown(store.dispose);
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false, itemId: 'episode-1'));
await _emit(
_event(
changeType: WatchStateChangeType.watched,
isNowWatched: true,
itemId: 'season-1',
parentChain: ['show-1'],
mediaType: 'season',
),
);
expect(store.patchForItem(_episode)?.isWatched, isTrue);
});
test('newer per-item patch overrides an older container mark', () async {
final store = WatchStateStore();
addTearDown(store.dispose);
await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'show-1'));
await _emit(_event(changeType: WatchStateChangeType.unwatched, isNowWatched: false, itemId: 'episode-1'));
expect(store.patchForItem(_episode)?.isWatched, isFalse);
});
test('ancestor patches resolve through the active client scope', () async {
final store = WatchStateStore();
addTearDown(store.dispose);
store.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'});
await _emit(
_event(
changeType: WatchStateChangeType.watched,
isNowWatched: true,
itemId: 'show-1',
cacheServerId: 'jf-machine/user-a',
),
);
expect(store.patchForItem(_episode)?.isWatched, isTrue);
});
test('applying a watched patch to a container also patches leaf counts', () async {
final store = WatchStateStore();
addTearDown(store.dispose);
await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'season-1'));
final season = MediaItem(
id: 'season-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
parentId: 'show-1',
serverId: 'jf-machine',
leafCount: 10,
viewedLeafCount: 3,
);
final resolved = store.apply(season);
expect(resolved.viewedLeafCount, 10);
expect(resolved.isWatched, isTrue);
});
}
@@ -1,6 +1,9 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/database/app_database.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/services/watch_state_resolver.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
@@ -80,4 +83,24 @@ void main() {
expect(snapshot.isEmpty, isTrue);
});
test('applying a watched snapshot patches container leaf counts so isWatched flips', () {
const snapshot = WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0);
final season = MediaItem(
id: 'season-1',
backend: MediaBackend.plex,
kind: MediaKind.season,
leafCount: 8,
viewedLeafCount: 2,
serverId: 'srv',
);
final resolved = snapshot.apply(season);
expect(resolved.viewedLeafCount, 8);
expect(resolved.isWatched, isTrue);
final unmarked = const WatchStateSnapshot(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0).apply(resolved);
expect(unmarked.viewedLeafCount, 0);
expect(unmarked.isWatched, isFalse);
});
}