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.
235 lines
8.0 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|