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:
+4
-4
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+45
-22
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user