Files
plezy/lib/providers/watch_state_store.dart
T
edde746 4eaf4423a1 refactor: share focus chrome and simplify the TV picker and browse paths
Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.

Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
2026-07-26 06:09:49 +02:00

235 lines
8.0 KiB
Dart

import 'dart:async';
import '../media/ids.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../media/media_item.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
import '../services/watch_state_resolver.dart';
import '../utils/global_key_utils.dart';
import '../utils/watch_state_notifier.dart';
@immutable
class HydratedWatchStatePatch {
final String globalKey;
final WatchStateSnapshot patch;
final int updatedAt;
final int order;
const HydratedWatchStatePatch({
required this.globalKey,
required this.patch,
required this.updatedAt,
required this.order,
});
}
class _WatchStatePatchEntry {
final WatchStateSnapshot patch;
final int updatedAt;
final int sequence;
final bool isSessionEvent;
const _WatchStatePatchEntry(
this.patch, {
required this.updatedAt,
required this.sequence,
required this.isSessionEvent,
});
bool isNewerThan(_WatchStatePatchEntry other) {
if (updatedAt != other.updatedAt) return updatedAt > other.updatedAt;
if (isSessionEvent != other.isSessionEvent) return isSessionEvent;
return sequence > other.sequence;
}
@override
bool operator ==(Object other) =>
other is _WatchStatePatchEntry &&
other.patch == patch &&
other.updatedAt == updatedAt &&
other.sequence == sequence &&
other.isSessionEvent == isSessionEvent;
@override
int get hashCode => Object.hash(patch, updatedAt, sequence, isSessionEvent);
}
/// The single session-local layer for watch-state freshness.
///
/// 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, _WatchStatePatchEntry> _patches = {};
final Map<String, _WatchStatePatchEntry> _hydratedPatches = {};
String? _activeProfileId;
Map<String, String?> _activeClientScopesByServer = const {};
int _sequence = 0;
_WatchStatePatchEntry? _exactEntryFor(String globalKey) {
final session = _patches[globalKey];
final hydrated = _hydratedPatches[globalKey];
if (session == null) return hydrated;
if (hydrated == null) return session;
return session.isNewerThan(hydrated) ? session : hydrated;
}
_WatchStatePatchEntry? _entryFor(String globalKey) {
final parsed = parseGlobalKey(globalKey);
if (parsed != null) {
final scoped = _activeClientScopesByServer[parsed.serverId];
if (scoped != null && scoped.isNotEmpty) {
return _exactEntryFor(buildGlobalKey(ServerId(scoped), parsed.ratingKey)) ?? _exactEntryFor(globalKey);
}
}
return _exactEntryFor(globalKey);
}
@visibleForTesting
WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
WatchStateSnapshot? 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.isNewerThan(best))) best = entry;
}
}
return best?.patch;
}
MediaItem apply(MediaItem item) {
return applyPatch(item, patchForItem(item));
}
List<MediaItem> applyAll(List<MediaItem> items) {
if (_patches.isEmpty && _hydratedPatches.isEmpty) return items;
return [for (final item in items) apply(item)];
}
static MediaItem applyPatch(MediaItem item, WatchStateSnapshot? patch) => patch == null ? item : patch.apply(item);
void setActiveProfileId(String? profileId) {
if (_activeProfileId == profileId) return;
_activeProfileId = profileId;
if (_patches.isEmpty && _hydratedPatches.isEmpty) return;
_patches.clear();
_hydratedPatches.clear();
safeNotifyListeners();
}
void setActiveClientScopesByServer(Map<String, String?> scopes) {
final normalized = <String, String?>{
for (final entry in scopes.entries)
if (entry.value != null && entry.value!.isNotEmpty && entry.value != entry.key) entry.key: entry.value,
};
if (mapEquals(_activeClientScopesByServer, normalized)) return;
_activeClientScopesByServer = Map.unmodifiable(normalized);
if (_patches.isNotEmpty || _hydratedPatches.isNotEmpty) safeNotifyListeners();
}
/// Replace the persisted local-action layer without disturbing newer
/// session events. Timestamps preserve freshness across item/ancestor keys.
void setHydratedPatches(Iterable<HydratedWatchStatePatch> patches) {
final next = <String, _WatchStatePatchEntry>{};
for (final hydrated in patches) {
final candidate = _WatchStatePatchEntry(
hydrated.patch,
updatedAt: hydrated.updatedAt,
sequence: hydrated.order,
isSessionEvent: false,
);
final existing = next[hydrated.globalKey];
if (existing == null || candidate.isNewerThan(existing)) {
next[hydrated.globalKey] = candidate;
}
}
if (mapEquals(_hydratedPatches, next)) return;
_hydratedPatches
..clear()
..addAll(next);
safeNotifyListeners();
}
void _onWatchStateEvent(WatchStateEvent event) {
final snapshot = WatchStateResolver.fromEvent(event);
if (snapshot.isEmpty) return;
final activeScope = _activeClientScopesByServer[event.serverId];
final eventScope = event.cacheServerId;
if (activeScope != null &&
activeScope.isNotEmpty &&
eventScope != null &&
eventScope.isNotEmpty &&
eventScope != event.serverId &&
eventScope != activeScope) {
return;
}
final resolvedScope = activeScope != null && activeScope.isNotEmpty ? activeScope : eventScope;
final key = resolvedScope != null && resolvedScope.isNotEmpty && resolvedScope != event.serverId
? buildGlobalKey(ServerId(resolvedScope), event.itemId)
: event.globalKey;
_patches[key] = _WatchStatePatchEntry(
snapshot,
updatedAt: DateTime.now().millisecondsSinceEpoch,
sequence: ++_sequence,
isSessionEvent: true,
);
safeNotifyListeners();
}
@override
void dispose() {
_subscription?.cancel();
_subscription = null;
super.dispose();
}
}
/// Point-of-use watch-state resolution. All fall back to the item as-is when
/// no [WatchStateStore] is in the tree (tests, isolated subtrees).
extension WatchStateResolution on BuildContext {
/// Build-time resolution: subscribes this context to the item's effective
/// patch, so the widget rebuilds when a newer event lands for it (or an
/// ancestor). Use in `build`.
MediaItem withFreshWatchState(MediaItem item) {
try {
final patch = select<WatchStateStore, WatchStateSnapshot?>((store) => store.patchForItem(item));
return WatchStateStore.applyPatch(item, patch);
} on ProviderNotFoundException {
return item;
}
}
/// Point-in-time resolution for handlers and non-build code paths.
MediaItem readFreshWatchState(MediaItem item) {
try {
return read<WatchStateStore>().apply(item);
} on ProviderNotFoundException {
return item;
}
}
List<MediaItem> readFreshWatchStateAll(List<MediaItem> items) {
try {
return read<WatchStateStore>().applyAll(items);
} on ProviderNotFoundException {
return items;
}
}
}