Pausing an episode on one device, finishing it on another and pressing Refresh left the first device showing the old "minutes left". Restarting the app showed the right value. Two independent defects produce that, and either alone reproduces the report. The first is the watch-state overlay. Every local watch event lands in WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites viewOffsetMs unconditionally; isNewerThan only ever orders one patch against another, never against the server row underneath. Nothing expires a patch and nothing clears the map except a profile switch, so the Mac's own paused position kept winning over every subsequent fetch until the process died. A patch exists to bridge the gap between a local action and the next server read of that item, so it should stop applying once that read happens. The store now records the watermark at which a successful authoritative response returned each key, and suppresses an acknowledged session patch at or below it. Only a watermark is stored, never the observed state: WatchStateSnapshot cannot hold a container's leaf counts, and keeping max() per key makes the order two concurrent responses complete irrelevant. Suppression is a read-time predicate, so nothing mutates during build. The barrier covers the parentChain too. patchForItem picks the newest of the item's own entry and its ancestors', so retiring only the item's entry would let an older season mark win and render watched/0 -- worse than either the stale value or the fresh one. An authoritative read of a child already reflects any container mark that preceded it, so the child's observation judges its ancestors as well; a newer container action still wins. Provenance decides what may be suppressed at all. WatchStateEvent now carries serverAcknowledged, defaulting to false so an unclassified emit site degrades to today's behaviour rather than silently becoming retireable. An offline write is owed to the server and a read must never retire it, so it stays until a WatchPatchPromotionNotifier promotion says the queue replayed it. That channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts to watched/unwatched by purging queued progress, so replaying one there would delete a newer rewatch. Promotion matches an exact WatchPatchId -- session minted for live crossings, derived from the persisted (profile, row, revision) for queued ones so it still joins after a restart. Report acceptance is not delivery: PlaybackReportSession resolves true for a same-state startup heartbeat it drops, so acknowledgement now keys on onDelivered. A MediaBrowser Started saves play count and last-played date but not the position, so it cannot acknowledge an offset. No report-derived watched crossing is acknowledged on any backend -- Jellyfin hard-codes its threshold and Plex never loads the server pref that would tell it the real one -- so only an awaited explicit markWatched settles one. The second defect is that a failed Refresh reported success. Plex _fetchHubs and the Jellyfin hub legs both degrade a failure to an empty list, and the library prefetch discarded its failures, so a server whose every hub request failed was recorded as succeeded; DiscoverProvider then kept the previous rows, set loaded and surfaced nothing. Worse, the background Continue Watching refresh wiped the row outright on zero success. Hub legs now report what they degraded through a HubFetchDiagnostics sink, which keeps partial rows alongside the failure and leaves every existing caller untouched. Failures ride through the aggregation results, a leg that could not run because discovery failed contributes that failure rather than a successful no-op, and loaded-server ids became succeeded - failed - cancelled so one bad leg no longer caches a server as covered and blocks its retry. The toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad snackbar on failure while the retained rows stay on screen. Rollback after a mid-pass exception is version-guarded, refilters against the current hidden libraries and no longer publishes a system shelf the pass never committed. Observations are staged with the pass and flushed only once the same disposal, generation and exception checks that authorise committing those rows have passed, so a discarded or rolled-back response can never suppress a patch. Also fixes a live data-loss race the promotion work would have built on: upsertProgressAction stamped a millisecond timestamp and updated the row in place, so a rewatch queued during an in-flight replay was deleted by id. Revisions are now strictly monotonic per row, replay deletes and retry updates compare against them, and the upsert resets the retry fields because a new revision is a new logical action. close #1829
989 lines
41 KiB
Dart
989 lines
41 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../media/ids.dart';
|
|
import '../media/media_hub.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
import '../mixins/event_aware.dart';
|
|
import '../services/settings_service.dart';
|
|
import '../services/data_aggregation_service.dart';
|
|
import '../services/system_shelf_service.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/coalesced_load_coordinator.dart';
|
|
import '../utils/deletion_notifier.dart';
|
|
import '../utils/media_event_keys.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import '../utils/media_hub_ordering.dart';
|
|
import '../utils/watch_state_notifier.dart';
|
|
import 'hidden_libraries_provider.dart';
|
|
import 'libraries_provider.dart';
|
|
import 'multi_server_provider.dart';
|
|
import 'watch_state_store.dart';
|
|
|
|
enum DiscoverLoadState { initial, loading, loaded, error }
|
|
|
|
enum DiscoverRefreshOutcome {
|
|
/// Both surfaces completed without a failed or cancelled server leg.
|
|
refreshed,
|
|
|
|
/// Some content refreshed, but at least one server leg failed or was cancelled.
|
|
degraded,
|
|
|
|
/// At least one server leg failed and none succeeded.
|
|
failed,
|
|
|
|
/// The pass was interrupted or had no attempted server legs.
|
|
cancelled,
|
|
}
|
|
|
|
DiscoverRefreshOutcome _refreshOutcome({
|
|
required Set<String> succeededServerIds,
|
|
required Set<String> failedServerIds,
|
|
required Set<String> cancelledServerIds,
|
|
required bool cancelled,
|
|
}) {
|
|
if (cancelled) return DiscoverRefreshOutcome.cancelled;
|
|
if (failedServerIds.isNotEmpty) {
|
|
return succeededServerIds.isEmpty ? DiscoverRefreshOutcome.failed : DiscoverRefreshOutcome.degraded;
|
|
}
|
|
if (cancelledServerIds.isNotEmpty) {
|
|
return succeededServerIds.isEmpty ? DiscoverRefreshOutcome.cancelled : DiscoverRefreshOutcome.degraded;
|
|
}
|
|
return succeededServerIds.isEmpty ? DiscoverRefreshOutcome.cancelled : DiscoverRefreshOutcome.refreshed;
|
|
}
|
|
|
|
/// Owns the Discover tab's data: the Continue Watching row and the home hub
|
|
/// list, including the refresh policy that used to live in the screen —
|
|
/// durable watch events refresh only Continue Watching (one on-deck call,
|
|
/// zero hub refetches), playback progress patches the visible row in place,
|
|
/// deletions drop the item from every visible list in place and then refresh
|
|
/// only Continue Watching, hidden-library changes trigger a full reload,
|
|
/// library-order changes re-sort hubs in place without refetching, and the
|
|
/// platform launcher shelf syncs from every on-deck update.
|
|
///
|
|
/// Lives inside the profile-keyed provider subtree, so a profile switch
|
|
/// resets it by construction. The screen is a consumer: it renders this
|
|
/// state and keeps only UI concerns (hero carousel, focus, spotlight).
|
|
class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
|
/// Preview row caps at 20; one extra item is fetched as a probe so
|
|
/// [hasMoreContinueWatching] can show the "more" affordance without a
|
|
/// second request.
|
|
static const int continueWatchingPreviewLimit = 20;
|
|
static const int _continueWatchingProbeLimit = continueWatchingPreviewLimit + 1;
|
|
|
|
DiscoverProvider(
|
|
this._multiServer,
|
|
this._hiddenLibraries,
|
|
this._libraries, {
|
|
required this.profileId,
|
|
required this.isProfileBinding,
|
|
WatchStateStore? watchStateStore,
|
|
Future<void> Function(String profileId, List<MediaItem>)? syncSystemShelf,
|
|
// A private field cannot be a named initializing formal callers can pass.
|
|
// ignore: prefer_initializing_formals
|
|
}) : _watchStateStore = watchStateStore,
|
|
_syncSystemShelfOverride = syncSystemShelf {
|
|
_loadCoordinator = CoalescedLoadCoordinator<String>(onFull: _loadOnce, onDelta: _loadDeltaOnce);
|
|
// Late server connects (reconnect after outage, slow wave) refresh
|
|
// discover the same way they refresh libraries. Removed in [dispose] so a
|
|
// profile switch can't leave a stale listener on the app-global provider.
|
|
_multiServer.addOnlineServersListener(syncToOnlineServers);
|
|
_hiddenLibraries.addListener(_onHiddenLibrariesChanged);
|
|
_lastSeenLibraryOrderKeys = _libraryOrderKeys();
|
|
_libraries.addListener(_onLibrariesChanged);
|
|
_watchStateSubscription = subscribeToHierarchicalEvents<WatchStateEvent>(
|
|
notifier: WatchStateNotifier(),
|
|
mounted: () => !isDisposed,
|
|
serverId: () => null,
|
|
globalKeys: () => _watchedGlobalKeys,
|
|
itemIds: () => _watchedIds,
|
|
onEvent: _onWatchStateChanged,
|
|
);
|
|
_deletionSubscription = subscribeToHierarchicalEvents<DeletionEvent>(
|
|
notifier: DeletionNotifier(),
|
|
mounted: () => !isDisposed,
|
|
serverId: () => null,
|
|
globalKeys: () => _deletionGlobalKeys,
|
|
itemIds: () => _deletionIds,
|
|
onEvent: _onDeletion,
|
|
);
|
|
}
|
|
|
|
final MultiServerProvider _multiServer;
|
|
final HiddenLibrariesProvider _hiddenLibraries;
|
|
final LibrariesProvider _libraries;
|
|
|
|
/// Authoritative fetches tell the store which items the server re-observed,
|
|
/// so a stale local watch patch stops overriding fresh server state (#1829).
|
|
/// Optional: tests and isolated subtrees may have no store.
|
|
final WatchStateStore? _watchStateStore;
|
|
final String? profileId;
|
|
|
|
/// Watermark plus store identity captured before an authoritative request.
|
|
/// Null when there is no store to reconcile against.
|
|
({int watermark, Object epoch})? _beginObservation() {
|
|
final store = _watchStateStore;
|
|
if (store == null) return null;
|
|
return (watermark: store.observationWatermark, epoch: store.observationEpoch);
|
|
}
|
|
|
|
/// Tell the store which items a *successful and committed* pass re-observed,
|
|
/// so their stale local watch patches stop overriding the fresh server rows.
|
|
///
|
|
/// Only ever called once the same disposed / generation / exception checks
|
|
/// that authorise committing those rows have passed. Recording earlier would
|
|
/// suppress a patch whose fresh row was then discarded or rolled back,
|
|
/// leaving an older snapshot on screen with nothing to correct it. A
|
|
/// zero-success pass observed nothing and records nothing.
|
|
void _recordObservations(
|
|
({int watermark, Object epoch})? observation,
|
|
List<({MediaItem item, String? clientScope})> rows,
|
|
Set<String> succeededServerIds,
|
|
) {
|
|
final store = _watchStateStore;
|
|
if (store == null || observation == null || succeededServerIds.isEmpty || rows.isEmpty) return;
|
|
store.recordObservations(
|
|
[
|
|
for (final row in rows)
|
|
if (succeededServerIds.contains(row.item.serverId)) row,
|
|
],
|
|
watermark: observation.watermark,
|
|
epoch: observation.epoch,
|
|
);
|
|
}
|
|
|
|
/// Whether the profile binder is still wiring servers — a no-servers load
|
|
/// during binding stays in the loading state instead of flashing an error,
|
|
/// and a zero-success pass during binding stays in the loading state
|
|
/// instead of flashing the empty placeholder (main_screen primes another
|
|
/// load once binding settles).
|
|
final bool Function() isProfileBinding;
|
|
final Future<void> Function(String profileId, List<MediaItem>)? _syncSystemShelfOverride;
|
|
|
|
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
|
|
StreamSubscription<DeletionEvent>? _deletionSubscription;
|
|
|
|
List<MediaItem> _onDeck = [];
|
|
List<MediaHub> _hubs = [];
|
|
bool _hasMoreContinueWatching = false;
|
|
DiscoverLoadState _onDeckState = DiscoverLoadState.initial;
|
|
DiscoverLoadState _hubsState = DiscoverLoadState.initial;
|
|
String? _errorMessage;
|
|
int _loadGeneration = 0;
|
|
int _contentRevision = 0;
|
|
int _commitRevision = 0;
|
|
DiscoverRefreshOutcome _lastOutcome = DiscoverRefreshOutcome.cancelled;
|
|
Future<void>? _continueWatchingRefreshFuture;
|
|
bool _continueWatchingRefreshQueued = false;
|
|
|
|
Set<String> _lastSeenHiddenKeys = {};
|
|
List<String> _lastSeenLibraryOrderKeys = const [];
|
|
|
|
/// Online servers whose Continue Watching legs succeeded without a failure
|
|
/// or cancellation in the current on-deck list. Tracked separately from hubs
|
|
/// so a transient failure in one surface does not cache the other as loaded
|
|
/// forever or force unnecessary refetches.
|
|
Set<String> _loadedOnDeckServerIds = {};
|
|
|
|
/// Online servers whose home-hub legs all succeeded in the current hub list.
|
|
Set<String> _loadedHubServerIds = {};
|
|
|
|
Set<String> get _fullyLoadedServerIds => _loadedOnDeckServerIds.intersection(_loadedHubServerIds);
|
|
|
|
late final CoalescedLoadCoordinator<String> _loadCoordinator;
|
|
|
|
Future<void>? _systemShelfSyncFuture;
|
|
List<MediaItem>? _pendingSystemShelfItems;
|
|
|
|
List<MediaItem> get onDeck => _onDeck;
|
|
List<MediaHub> get hubs => _hubs;
|
|
bool get hasMoreContinueWatching => _hasMoreContinueWatching;
|
|
|
|
/// Raw load failure (unlocalized); the screen wraps it for display.
|
|
String? get errorMessage => _errorMessage;
|
|
|
|
/// True until the first on-deck result (or error) of a [load] pass lands.
|
|
bool get isLoading => _onDeckState == DiscoverLoadState.initial || _onDeckState == DiscoverLoadState.loading;
|
|
|
|
bool get areHubsLoading => _hubsState == DiscoverLoadState.initial || _hubsState == DiscoverLoadState.loading;
|
|
|
|
/// Bumped each time a [load] pass replaces the on-deck list. The screen
|
|
/// uses this to distinguish "full reload — reset the hero carousel" from
|
|
/// a background Continue Watching refresh (clamp only).
|
|
int get loadGeneration => _loadGeneration;
|
|
|
|
/// Refresh when a server comes online *mid-session* (reconnect, late wave) —
|
|
/// its hubs and continue-watching rows are otherwise missing until a manual
|
|
/// refresh. During profile binding this is a no-op: servers bind in waves
|
|
/// and main_screen primes one [load] when binding settles, so reacting to
|
|
/// each wave would multiply the (expensive) hub fan-out at startup.
|
|
///
|
|
/// Once a full pass has loaded, only the genuinely new servers are fetched
|
|
/// and merged in; already-loaded servers are not refetched.
|
|
Future<void> syncToOnlineServers(Set<String> onlineServerIds) {
|
|
if (isDisposed || onlineServerIds.isEmpty || isProfileBinding()) return Future<void>.value();
|
|
if (_onDeckState == DiscoverLoadState.loaded &&
|
|
_hubsState == DiscoverLoadState.loaded &&
|
|
_fullyLoadedServerIds.containsAll(onlineServerIds)) {
|
|
return Future<void>.value();
|
|
}
|
|
// Nothing (or a failed pass) to merge into yet — run the full load.
|
|
if (_onDeckState != DiscoverLoadState.loaded || _hubsState != DiscoverLoadState.loaded) return load();
|
|
return _loadCoordinator.requestDelta(onlineServerIds.difference(_fullyLoadedServerIds));
|
|
}
|
|
|
|
/// Full load of Continue Watching + hubs. Concurrent calls coalesce into
|
|
/// the in-flight pass plus at most one trailing pass (so a request that
|
|
/// arrives mid-load still observes its own fresh fetch).
|
|
Future<void> load() {
|
|
if (isDisposed) return Future<void>.value();
|
|
return _loadCoordinator.requestFull();
|
|
}
|
|
|
|
Future<DiscoverRefreshOutcome> refreshNow() async {
|
|
if (isDisposed) return DiscoverRefreshOutcome.cancelled;
|
|
await _loadCoordinator.requestFull();
|
|
if (isDisposed) return DiscoverRefreshOutcome.cancelled;
|
|
return _lastOutcome;
|
|
}
|
|
|
|
/// Whether a [load] pass is already running. The startup online-entry hook
|
|
/// uses this to skip a prime that would only duplicate the load the screen
|
|
/// started in `initState`.
|
|
bool get isLoadInFlight => _loadCoordinator.isBusy;
|
|
|
|
Future<void> _loadOnce() async {
|
|
var outcome = DiscoverRefreshOutcome.cancelled;
|
|
var passClearedExceptionBoundary = false;
|
|
List<MediaItem>? systemShelfPassToken;
|
|
// Observations are staged with the pass, not recorded as each leg lands:
|
|
// suppressing a patch whose fresh row is then discarded or rolled back
|
|
// would leave an older snapshot on screen with nothing to correct it.
|
|
final pendingObservations = <({MediaItem item, String? clientScope})>[];
|
|
final observedServerIds = <String>{};
|
|
// Assigned inside the try, after the preparatory awaits, so the watermark
|
|
// brackets the network calls rather than the whole method.
|
|
({int watermark, Object epoch})? observation;
|
|
final succeededServerIds = <String>{};
|
|
final failedServerIds = <String>{};
|
|
final cancelledServerIds = <String>{};
|
|
final previousOnDeck = _onDeck;
|
|
final previousHubs = _hubs;
|
|
final previousHasMoreContinueWatching = _hasMoreContinueWatching;
|
|
final previousLoadedOnDeckServerIds = _loadedOnDeckServerIds;
|
|
final previousLoadedHubServerIds = _loadedHubServerIds;
|
|
final previousOnDeckState = _onDeckState;
|
|
final previousHubsState = _hubsState;
|
|
final previousLoadGeneration = _loadGeneration;
|
|
final previousCommitRevision = _commitRevision;
|
|
var expectedCommitRevision = previousCommitRevision;
|
|
var onDeckFetchCompleted = false;
|
|
var hubFetchCompleted = false;
|
|
var replacedOnDeck = false;
|
|
|
|
try {
|
|
// Yield to the microtask queue before the first notify so a load()
|
|
// kicked off during build (the screen's initState) doesn't mark
|
|
// listening widgets dirty mid-build.
|
|
await null;
|
|
if (isDisposed) return;
|
|
++_contentRevision;
|
|
appLogger.d('DiscoverProvider: loading content from all servers');
|
|
_onDeckState = DiscoverLoadState.loading;
|
|
_hubsState = DiscoverLoadState.loading;
|
|
_errorMessage = null;
|
|
safeNotifyListeners();
|
|
|
|
if (!_multiServer.hasConnectedServers) {
|
|
if (isProfileBinding()) return;
|
|
throw Exception('No servers available');
|
|
}
|
|
|
|
await _hiddenLibraries.ensureInitialized();
|
|
if (isDisposed) return;
|
|
_lastSeenHiddenKeys = Set.of(_hiddenLibraries.hiddenLibraryKeys);
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
if (isDisposed) return;
|
|
final useGlobalHubs = settings.read(SettingsService.useGlobalHubs);
|
|
final aggregation = _multiServer.aggregationService;
|
|
|
|
// On-deck and hubs fetch in parallel; on-deck is published as soon as
|
|
// it lands so the hero renders while hubs are still loading.
|
|
//
|
|
// The watermark is captured immediately before the requests, after
|
|
// every preparatory await: a patch recorded later has a higher sequence
|
|
// and must survive this pass's observations.
|
|
observation = _beginObservation();
|
|
final onDeckFuture = aggregation.getOnDeckFromAllServers(
|
|
limit: _continueWatchingProbeLimit,
|
|
hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys,
|
|
);
|
|
final hubsFuture = aggregation.getHubsFromAllServers(
|
|
hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys,
|
|
useGlobalHubs: useGlobalHubs,
|
|
includePlaybackHubs: false,
|
|
);
|
|
|
|
// A pass in which zero servers succeeded is never authoritative: it
|
|
// must not wipe existing content, and it may only commit "loaded,
|
|
// empty" when the failure is settled — not a client-side abort
|
|
// (teardown mid-fetch) and not mid-binding. In both of those cases a
|
|
// follow-up load is guaranteed (binding-settle prime, or
|
|
// syncToOnlineServers falling through to load() while not loaded).
|
|
final fetchedOnDeck = await onDeckFuture;
|
|
onDeckFetchCompleted = true;
|
|
succeededServerIds.addAll(fetchedOnDeck.succeededServerIds);
|
|
failedServerIds.addAll(fetchedOnDeck.failedServerIds);
|
|
cancelledServerIds.addAll(fetchedOnDeck.cancelledServerIds);
|
|
pendingObservations.addAll(fetchedOnDeck.observedItems);
|
|
observedServerIds.addAll(fetchedOnDeck.succeededServerIds);
|
|
if (isDisposed) return;
|
|
if (fetchedOnDeck.succeededServerIds.isEmpty && _onDeck.isNotEmpty) {
|
|
// Keep the stale rows; the empty succeeded set makes the next status
|
|
// emission refetch every server.
|
|
appLogger.w('DiscoverProvider: on-deck pass failed on all servers; keeping previous items');
|
|
_onDeckState = DiscoverLoadState.loaded;
|
|
_loadedOnDeckServerIds = _authoritativeSucceededServerIds(
|
|
fetchedOnDeck.succeededServerIds,
|
|
fetchedOnDeck.failedServerIds,
|
|
fetchedOnDeck.cancelledServerIds,
|
|
);
|
|
safeNotifyListeners();
|
|
} else if (fetchedOnDeck.succeededServerIds.isEmpty &&
|
|
(fetchedOnDeck.cancelledServerIds.isNotEmpty || isProfileBinding())) {
|
|
// Disrupted with nothing to show yet: stay in loading so the screen
|
|
// keeps its skeleton instead of flashing the empty placeholder.
|
|
// Don't return — the hubs fetch is still in flight below.
|
|
appLogger.d('DiscoverProvider: on-deck pass disrupted with no prior content; keeping loading state');
|
|
} else {
|
|
_applyOnDeck(fetchedOnDeck.items);
|
|
++expectedCommitRevision;
|
|
replacedOnDeck = true;
|
|
_onDeckState = DiscoverLoadState.loaded;
|
|
_loadedOnDeckServerIds = _authoritativeSucceededServerIds(
|
|
fetchedOnDeck.succeededServerIds,
|
|
fetchedOnDeck.failedServerIds,
|
|
fetchedOnDeck.cancelledServerIds,
|
|
);
|
|
systemShelfPassToken = List<MediaItem>.unmodifiable(_onDeck);
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
final fetchedHubs = await hubsFuture;
|
|
hubFetchCompleted = true;
|
|
succeededServerIds.addAll(fetchedHubs.succeededServerIds);
|
|
failedServerIds.addAll(fetchedHubs.failedServerIds);
|
|
pendingObservations.addAll(fetchedHubs.observedItems);
|
|
observedServerIds.addAll(fetchedHubs.succeededServerIds);
|
|
cancelledServerIds.addAll(fetchedHubs.cancelledServerIds);
|
|
if (isDisposed) return;
|
|
|
|
if (fetchedHubs.succeededServerIds.isEmpty && _hubs.isNotEmpty) {
|
|
appLogger.w('DiscoverProvider: hub pass failed on all servers; keeping previous hubs');
|
|
_hubsState = DiscoverLoadState.loaded;
|
|
_loadedHubServerIds = _authoritativeSucceededServerIds(
|
|
fetchedHubs.succeededServerIds,
|
|
fetchedHubs.failedServerIds,
|
|
fetchedHubs.cancelledServerIds,
|
|
);
|
|
safeNotifyListeners();
|
|
outcome = _refreshOutcome(
|
|
succeededServerIds: succeededServerIds,
|
|
failedServerIds: failedServerIds,
|
|
cancelledServerIds: cancelledServerIds,
|
|
cancelled: isProfileBinding(),
|
|
);
|
|
if (replacedOnDeck && outcome != DiscoverRefreshOutcome.failed && outcome != DiscoverRefreshOutcome.cancelled) {
|
|
++_loadGeneration;
|
|
}
|
|
passClearedExceptionBoundary = true;
|
|
return;
|
|
}
|
|
if (fetchedHubs.succeededServerIds.isEmpty && (fetchedHubs.cancelledServerIds.isNotEmpty || isProfileBinding())) {
|
|
appLogger.d('DiscoverProvider: hub pass disrupted with no prior content; keeping loading state');
|
|
outcome = _refreshOutcome(
|
|
succeededServerIds: succeededServerIds,
|
|
failedServerIds: failedServerIds,
|
|
cancelledServerIds: cancelledServerIds,
|
|
cancelled: isProfileBinding(),
|
|
);
|
|
if (replacedOnDeck && outcome != DiscoverRefreshOutcome.failed && outcome != DiscoverRefreshOutcome.cancelled) {
|
|
++_loadGeneration;
|
|
}
|
|
passClearedExceptionBoundary = true;
|
|
return;
|
|
}
|
|
|
|
final filteredHubs = _filterDiscoverHubs(fetchedHubs.hubs);
|
|
sortMediaHubsByLibraryOrder(filteredHubs, _libraries.libraries);
|
|
|
|
appLogger.d('DiscoverProvider: ${_onDeck.length} on-deck items, ${filteredHubs.length} hubs');
|
|
_replaceHubs(filteredHubs);
|
|
++expectedCommitRevision;
|
|
_hubsState = DiscoverLoadState.loaded;
|
|
_loadedHubServerIds = _authoritativeSucceededServerIds(
|
|
fetchedHubs.succeededServerIds,
|
|
fetchedHubs.failedServerIds,
|
|
fetchedHubs.cancelledServerIds,
|
|
);
|
|
outcome = _refreshOutcome(
|
|
succeededServerIds: succeededServerIds,
|
|
failedServerIds: failedServerIds,
|
|
cancelledServerIds: cancelledServerIds,
|
|
cancelled: isProfileBinding(),
|
|
);
|
|
if (replacedOnDeck && outcome != DiscoverRefreshOutcome.failed && outcome != DiscoverRefreshOutcome.cancelled) {
|
|
++_loadGeneration;
|
|
}
|
|
passClearedExceptionBoundary = true;
|
|
safeNotifyListeners();
|
|
} catch (e) {
|
|
if (isDisposed) return;
|
|
outcome = isProfileBinding() ? DiscoverRefreshOutcome.cancelled : DiscoverRefreshOutcome.failed;
|
|
appLogger.e('Failed to load discover content', error: e);
|
|
final hadPriorContent = previousOnDeck.isNotEmpty || previousHubs.isNotEmpty;
|
|
if (!hadPriorContent) {
|
|
_errorMessage = e.toString();
|
|
_onDeckState = DiscoverLoadState.error;
|
|
_hubsState = DiscoverLoadState.error;
|
|
safeNotifyListeners();
|
|
return;
|
|
}
|
|
|
|
if (_commitRevision == expectedCommitRevision) {
|
|
_replaceOnDeck(
|
|
_withoutHiddenLibraries(previousOnDeck, _hiddenLibraries.hiddenLibraryKeys),
|
|
hasMore: previousHasMoreContinueWatching,
|
|
);
|
|
_replaceHubs(_hubsWithoutHiddenLibraries(previousHubs, _hiddenLibraries.hiddenLibraryKeys));
|
|
_loadedOnDeckServerIds = Set<String>.of(previousLoadedOnDeckServerIds);
|
|
_loadedHubServerIds = Set<String>.of(previousLoadedHubServerIds);
|
|
_onDeckState = previousOnDeckState;
|
|
_hubsState = previousHubsState;
|
|
_loadGeneration = previousLoadGeneration;
|
|
} else {
|
|
_filterCurrentContentForHiddenLibraries();
|
|
}
|
|
|
|
final hiddenServerIds = _serverIdsForLibraryKeys(_hiddenLibraries.hiddenLibraryKeys);
|
|
if (!onDeckFetchCompleted) {
|
|
_loadedOnDeckServerIds = {};
|
|
} else {
|
|
_loadedOnDeckServerIds = Set<String>.of(_loadedOnDeckServerIds)
|
|
..removeAll(failedServerIds)
|
|
..removeAll(cancelledServerIds);
|
|
}
|
|
if (!hubFetchCompleted) {
|
|
_loadedHubServerIds = {};
|
|
} else {
|
|
_loadedHubServerIds = Set<String>.of(_loadedHubServerIds)
|
|
..removeAll(failedServerIds)
|
|
..removeAll(cancelledServerIds);
|
|
}
|
|
_loadedOnDeckServerIds = Set<String>.of(_loadedOnDeckServerIds)..removeAll(hiddenServerIds);
|
|
_loadedHubServerIds = Set<String>.of(_loadedHubServerIds)..removeAll(hiddenServerIds);
|
|
_errorMessage = null;
|
|
_onDeckState = DiscoverLoadState.loaded;
|
|
_hubsState = DiscoverLoadState.loaded;
|
|
safeNotifyListeners();
|
|
} finally {
|
|
_lastOutcome = outcome;
|
|
if (passClearedExceptionBoundary && _commitRevision == expectedCommitRevision && !isDisposed) {
|
|
_recordObservations(observation, pendingObservations, observedServerIds);
|
|
if (systemShelfPassToken != null) unawaited(_syncSystemShelf(systemShelfPassToken));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch Continue Watching + hubs from [serverIds] only (servers that came
|
|
/// online after the last full pass) and merge them into the loaded state.
|
|
/// Failures keep the loaded state and leave the ids un-loaded, so the next
|
|
/// status emission retries them.
|
|
Future<void> _loadDeltaOnce(Set<String> serverIds) async {
|
|
++_contentRevision;
|
|
// A full pass may have covered these ids while they sat in the queue.
|
|
final ids = serverIds.difference(_fullyLoadedServerIds);
|
|
final onDeckIds = ids.difference(_loadedOnDeckServerIds);
|
|
final hubIds = ids.difference(_loadedHubServerIds);
|
|
if (onDeckIds.isEmpty && hubIds.isEmpty) return;
|
|
appLogger.d('DiscoverProvider: merging content from newly-online servers $ids (onDeck=$onDeckIds, hubs=$hubIds)');
|
|
|
|
try {
|
|
await _hiddenLibraries.ensureInitialized();
|
|
if (isDisposed) return;
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
if (isDisposed) return;
|
|
final useGlobalHubs = settings.read(SettingsService.useGlobalHubs);
|
|
final aggregation = _multiServer.aggregationService;
|
|
|
|
final observation = _beginObservation();
|
|
final Future<OnDeckAggregationResult?> onDeckFuture = onDeckIds.isEmpty
|
|
? Future<OnDeckAggregationResult?>.value()
|
|
: aggregation.getOnDeckFromAllServers(
|
|
limit: _continueWatchingProbeLimit,
|
|
hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys,
|
|
serverIds: onDeckIds,
|
|
);
|
|
final Future<HubAggregationResult?> hubsFuture = hubIds.isEmpty
|
|
? Future<HubAggregationResult?>.value()
|
|
: aggregation.getHubsFromAllServers(
|
|
hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys,
|
|
useGlobalHubs: useGlobalHubs,
|
|
includePlaybackHubs: false,
|
|
serverIds: hubIds,
|
|
);
|
|
|
|
final freshOnDeck = await onDeckFuture;
|
|
final freshHubs = await hubsFuture;
|
|
if (isDisposed) return;
|
|
// Staged, not recorded here: the merge below can still throw, and the
|
|
// catch keeps the previous rows. Suppressing patches against rows that
|
|
// were then discarded would strand an older snapshot on screen.
|
|
final pendingObservations = <({MediaItem item, String? clientScope})>[];
|
|
final observedServerIds = <String>{};
|
|
|
|
if (freshOnDeck != null) {
|
|
final hadMore = _hasMoreContinueWatching;
|
|
final mergedOnDeck = await aggregation.mergeContinueWatching(
|
|
_onDeck,
|
|
freshOnDeck.items,
|
|
limit: _continueWatchingProbeLimit,
|
|
);
|
|
if (isDisposed) return;
|
|
_applyOnDeck(mergedOnDeck);
|
|
// The stored list is already trimmed, so the merge can't see old items
|
|
// past the cap — a previously-true "more" affordance stays true.
|
|
if (hadMore) _hasMoreContinueWatching = true;
|
|
_loadedOnDeckServerIds = {..._loadedOnDeckServerIds, ...freshOnDeck.succeededServerIds}
|
|
..removeAll(freshOnDeck.failedServerIds)
|
|
..removeAll(freshOnDeck.cancelledServerIds);
|
|
// No _loadGeneration bump: a delta behaves like the background Continue
|
|
// Watching refresh (the hero clamps instead of resetting).
|
|
}
|
|
|
|
if (freshHubs != null) {
|
|
final succeededHubIds = freshHubs.succeededServerIds;
|
|
final mergedHubs = [
|
|
..._hubs.where((hub) => hub.serverId == null || !succeededHubIds.contains(hub.serverId)),
|
|
..._filterDiscoverHubs(freshHubs.hubs),
|
|
];
|
|
sortMediaHubsByLibraryOrder(mergedHubs, _libraries.libraries);
|
|
_replaceHubs(mergedHubs);
|
|
_loadedHubServerIds = {..._loadedHubServerIds, ...succeededHubIds}
|
|
..removeAll(freshHubs.failedServerIds)
|
|
..removeAll(freshHubs.cancelledServerIds);
|
|
}
|
|
|
|
if (freshOnDeck != null) {
|
|
pendingObservations.addAll(freshOnDeck.observedItems);
|
|
observedServerIds.addAll(freshOnDeck.succeededServerIds);
|
|
}
|
|
if (freshHubs != null) {
|
|
pendingObservations.addAll(freshHubs.observedItems);
|
|
observedServerIds.addAll(freshHubs.succeededServerIds);
|
|
}
|
|
_recordObservations(observation, pendingObservations, observedServerIds);
|
|
|
|
appLogger.d('DiscoverProvider: ${_onDeck.length} on-deck items, ${_hubs.length} hubs after merging $ids');
|
|
safeNotifyListeners();
|
|
unawaited(_syncSystemShelf(_onDeck));
|
|
} catch (e) {
|
|
if (isDisposed) return;
|
|
// Keep the loaded state — stale rows beat an error flash.
|
|
appLogger.w('DiscoverProvider: delta load failed for $ids', error: e);
|
|
}
|
|
}
|
|
|
|
/// Playback-progress hubs duplicate the top Continue Watching row.
|
|
List<MediaHub> _filterDiscoverHubs(List<MediaHub> hubs) {
|
|
return hubs.where((hub) {
|
|
final hubId = hub.identifier?.toLowerCase() ?? '';
|
|
final title = hub.title.toLowerCase();
|
|
return !hubId.contains('ondeck') &&
|
|
!hubId.contains('continue') &&
|
|
!hubId.contains('nextup') &&
|
|
!title.contains('continue watching') &&
|
|
!title.contains('on deck') &&
|
|
!title.contains('next up');
|
|
}).toList();
|
|
}
|
|
|
|
Set<String> _authoritativeSucceededServerIds(
|
|
Set<String> succeededServerIds,
|
|
Set<String> failedServerIds,
|
|
Set<String> cancelledServerIds,
|
|
) {
|
|
return Set<String>.of(succeededServerIds)
|
|
..removeAll(failedServerIds)
|
|
..removeAll(cancelledServerIds);
|
|
}
|
|
|
|
List<MediaItem> _withoutHiddenLibraries(List<MediaItem> items, Set<String> hiddenLibraryKeys) {
|
|
if (hiddenLibraryKeys.isEmpty) return items;
|
|
return items.where((item) {
|
|
final libraryKey = item.libraryGlobalKey;
|
|
return libraryKey == null || !hiddenLibraryKeys.contains(libraryKey);
|
|
}).toList();
|
|
}
|
|
|
|
List<MediaHub> _hubsWithoutHiddenLibraries(List<MediaHub> hubs, Set<String> hiddenLibraryKeys) {
|
|
if (hiddenLibraryKeys.isEmpty) return hubs;
|
|
final filteredHubs = <MediaHub>[];
|
|
for (final hub in hubs) {
|
|
final filteredItems = hub.items.where((item) {
|
|
var libraryKey = item.libraryGlobalKey;
|
|
final libraryId = item.libraryId;
|
|
final serverId = item.serverId ?? hub.serverId;
|
|
if (libraryKey == null && libraryId != null && serverId != null) {
|
|
libraryKey = buildGlobalKey(ServerId(serverId), libraryId);
|
|
}
|
|
return libraryKey == null || !hiddenLibraryKeys.contains(libraryKey);
|
|
}).toList();
|
|
if (filteredItems.isNotEmpty) {
|
|
filteredHubs.add(
|
|
filteredItems.length == hub.items.length
|
|
? hub
|
|
: hub.copyWith(items: filteredItems, size: filteredItems.length),
|
|
);
|
|
}
|
|
}
|
|
return filteredHubs;
|
|
}
|
|
|
|
Set<String> _serverIdsForLibraryKeys(Set<String> libraryKeys) {
|
|
final serverIds = <String>{};
|
|
for (final key in libraryKeys) {
|
|
final parsed = parseGlobalKey(key);
|
|
if (parsed != null) serverIds.add(parsed.serverId);
|
|
}
|
|
return serverIds;
|
|
}
|
|
|
|
void _filterCurrentContentForHiddenLibraries() {
|
|
final hiddenLibraryKeys = _hiddenLibraries.hiddenLibraryKeys;
|
|
final filteredOnDeck = _withoutHiddenLibraries(_onDeck, hiddenLibraryKeys);
|
|
if (filteredOnDeck.length != _onDeck.length) {
|
|
_replaceOnDeck(filteredOnDeck, hasMore: _hasMoreContinueWatching);
|
|
}
|
|
final filteredHubs = _hubsWithoutHiddenLibraries(_hubs, hiddenLibraryKeys);
|
|
if (!listEquals(filteredHubs, _hubs)) {
|
|
_replaceHubs(filteredHubs);
|
|
}
|
|
}
|
|
|
|
/// Background refresh of Continue Watching only. Concurrent events coalesce
|
|
/// into the active request plus at most one trailing fresh request.
|
|
Future<void> refreshContinueWatching() {
|
|
final active = _continueWatchingRefreshFuture;
|
|
if (active != null) {
|
|
_continueWatchingRefreshQueued = true;
|
|
return active;
|
|
}
|
|
late final Future<void> refresh;
|
|
refresh = _runContinueWatchingRefreshes().whenComplete(() {
|
|
if (identical(_continueWatchingRefreshFuture, refresh)) {
|
|
_continueWatchingRefreshFuture = null;
|
|
}
|
|
});
|
|
_continueWatchingRefreshFuture = refresh;
|
|
return refresh;
|
|
}
|
|
|
|
Future<void> _runContinueWatchingRefreshes() async {
|
|
do {
|
|
_continueWatchingRefreshQueued = false;
|
|
await _refreshContinueWatchingOnce();
|
|
} while (_continueWatchingRefreshQueued && !isDisposed);
|
|
}
|
|
|
|
Future<void> _refreshContinueWatchingOnce() async {
|
|
try {
|
|
if (!_multiServer.hasConnectedServers) return;
|
|
final revision = _contentRevision;
|
|
final hiddenKeys = Set<String>.of(_hiddenLibraries.hiddenLibraryKeys);
|
|
final observation = _beginObservation();
|
|
final fetched = await _multiServer.aggregationService.getOnDeckFromAllServers(
|
|
limit: _continueWatchingProbeLimit,
|
|
hiddenLibraryKeys: hiddenKeys,
|
|
);
|
|
if (isDisposed) return;
|
|
if (revision != _contentRevision) {
|
|
// A newer mutation landed while this was in flight, so these rows are
|
|
// discarded — recording them would suppress patches against data the
|
|
// user never sees.
|
|
_continueWatchingRefreshQueued = true;
|
|
return;
|
|
}
|
|
_loadedOnDeckServerIds = _authoritativeSucceededServerIds(
|
|
fetched.succeededServerIds,
|
|
fetched.failedServerIds,
|
|
fetched.cancelledServerIds,
|
|
);
|
|
if (fetched.succeededServerIds.isEmpty) {
|
|
safeNotifyListeners();
|
|
return;
|
|
}
|
|
_applyOnDeck(fetched.items);
|
|
_recordObservations(observation, fetched.observedItems, fetched.succeededServerIds);
|
|
safeNotifyListeners();
|
|
unawaited(_syncSystemShelf(_onDeck));
|
|
} catch (e) {
|
|
appLogger.w('Failed to refresh Continue Watching', error: e);
|
|
}
|
|
}
|
|
|
|
/// The full unlimited Continue Watching list for the hub's load-more path.
|
|
Future<List<MediaItem>> loadAllContinueWatching() async {
|
|
if (!_multiServer.hasConnectedServers) return const [];
|
|
await _hiddenLibraries.ensureInitialized();
|
|
if (isDisposed) return const [];
|
|
final observation = _beginObservation();
|
|
final fetched = await _multiServer.aggregationService.getOnDeckFromAllServers(
|
|
hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys,
|
|
);
|
|
// "View All" renders through the same overlay as the row it expands, so
|
|
// it has to reconcile too or the stale patch simply reappears there.
|
|
_recordObservations(observation, fetched.observedItems, fetched.succeededServerIds);
|
|
return fetched.items;
|
|
}
|
|
|
|
/// Refetch a single item (post-edit refresh from a hub row) through its
|
|
/// source server and swap it into whichever lists contain that qualified
|
|
/// identity.
|
|
Future<void> updateItem(MediaItem source) async {
|
|
final serverId = source.serverId;
|
|
if (serverId == null) return;
|
|
|
|
try {
|
|
final updated = await _multiServer.getClientForServer(ServerId(serverId))?.fetchItem(source.id);
|
|
if (updated == null || isDisposed) return;
|
|
_updateItemInLists(source.globalKey, updated);
|
|
safeNotifyListeners();
|
|
} catch (_) {
|
|
// Silently fail — the item will refresh on the next full reload.
|
|
}
|
|
}
|
|
|
|
void _updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
|
final onDeckIndex = _onDeck.indexWhere((item) => item.globalKey == sourceGlobalKey);
|
|
if (onDeckIndex != -1) {
|
|
_replaceOnDeck(List.of(_onDeck)..[onDeckIndex] = updatedItem, hasMore: _hasMoreContinueWatching);
|
|
}
|
|
|
|
for (var i = 0; i < _hubs.length; i++) {
|
|
final hub = _hubs[i];
|
|
final itemIndex = hub.items.indexWhere((item) => item.globalKey == sourceGlobalKey);
|
|
if (itemIndex != -1) {
|
|
final newItems = List<MediaItem>.from(hub.items);
|
|
newItems[itemIndex] = updatedItem;
|
|
_replaceHubs(List.of(_hubs)..[i] = hub.copyWith(items: newItems));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _applyOnDeck(List<MediaItem> fetched) {
|
|
final hasMore = fetched.length > continueWatchingPreviewLimit;
|
|
_replaceOnDeck(hasMore ? fetched.take(continueWatchingPreviewLimit).toList() : fetched, hasMore: hasMore);
|
|
}
|
|
|
|
void _replaceOnDeck(List<MediaItem> onDeck, {required bool hasMore}) {
|
|
_onDeck = onDeck;
|
|
_hasMoreContinueWatching = hasMore;
|
|
++_commitRevision;
|
|
}
|
|
|
|
void _replaceHubs(List<MediaHub> hubs) {
|
|
_hubs = hubs;
|
|
++_commitRevision;
|
|
}
|
|
|
|
// --- Event reactions -----------------------------------------------------
|
|
|
|
/// Watch on-deck items and their parent shows/seasons (an episode's watch
|
|
/// flip changes what Continue Watching should show for its series).
|
|
Set<String>? get _watchedIds => hierarchicalEventIds(_onDeck);
|
|
|
|
Set<String>? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck);
|
|
|
|
void _onWatchStateChanged(WatchStateEvent event) {
|
|
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
|
|
final viewOffset = event.viewOffset;
|
|
final index = _onDeck.indexWhere((item) => item.globalKey == event.globalKey);
|
|
if (viewOffset != null && index != -1 && _onDeck[index].viewOffsetMs != viewOffset) {
|
|
_replaceOnDeck(
|
|
List.of(_onDeck)..[index] = _onDeck[index].copyWith(viewOffsetMs: viewOffset),
|
|
hasMore: _hasMoreContinueWatching,
|
|
);
|
|
safeNotifyListeners();
|
|
unawaited(_syncSystemShelf(_onDeck));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
|
|
_evictFromOnDeck((item) => item.id == event.itemId);
|
|
} else if (event.changeType == WatchStateChangeType.watched ||
|
|
(event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched == true)) {
|
|
// Finished items have no business in Continue Watching, so drop the row
|
|
// now instead of waiting a round trip for the refetch below to confirm
|
|
// it. Marking a season or show watched takes its on-deck episode with
|
|
// it, matching the parent-aware filter this subscription uses — the
|
|
// series' successor comes back from the refetch (#1812).
|
|
_evictFromOnDeck(
|
|
(item) => item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId,
|
|
);
|
|
}
|
|
unawaited(refreshContinueWatching());
|
|
}
|
|
|
|
void _evictFromOnDeck(bool Function(MediaItem item) matches) {
|
|
final remaining = _onDeck.where((item) => !matches(item)).toList();
|
|
if (remaining.length == _onDeck.length) return;
|
|
_replaceOnDeck(remaining, hasMore: _hasMoreContinueWatching);
|
|
safeNotifyListeners();
|
|
unawaited(_syncSystemShelf(_onDeck));
|
|
}
|
|
|
|
/// Everything on screen: the Continue Watching row plus every hub row.
|
|
Iterable<MediaItem> get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items));
|
|
|
|
/// Deletions can affect any visible list, so the filter covers on-deck and
|
|
/// hub items plus their parents (a deleted season/show takes its visible
|
|
/// episodes with it).
|
|
Set<String>? get _deletionIds => hierarchicalEventIds(_visibleItems);
|
|
|
|
Set<String>? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems);
|
|
|
|
void _onDeletion(DeletionEvent event) {
|
|
// On-deck and hubs are server-backed: a download-only deletion leaves the
|
|
// server item in place, so it must not evict anything here.
|
|
if (event.isDownloadOnly) return;
|
|
|
|
bool affected(MediaItem item) =>
|
|
item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId;
|
|
|
|
var changed = false;
|
|
final remainingOnDeck = _onDeck.where((item) => !affected(item)).toList();
|
|
if (remainingOnDeck.length != _onDeck.length) {
|
|
_replaceOnDeck(remainingOnDeck, hasMore: _hasMoreContinueWatching);
|
|
changed = true;
|
|
}
|
|
for (var i = 0; i < _hubs.length; i++) {
|
|
final hub = _hubs[i];
|
|
final newItems = hub.items.where((item) => !affected(item)).toList();
|
|
if (newItems.length != hub.items.length) {
|
|
_replaceHubs(List.of(_hubs)..[i] = hub.copyWith(items: newItems));
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) safeNotifyListeners();
|
|
unawaited(refreshContinueWatching());
|
|
}
|
|
|
|
void _onHiddenLibrariesChanged() {
|
|
final currentKeys = _hiddenLibraries.hiddenLibraryKeys;
|
|
if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) {
|
|
return;
|
|
}
|
|
_lastSeenHiddenKeys = Set.of(currentKeys);
|
|
unawaited(load());
|
|
}
|
|
|
|
void _onLibrariesChanged() {
|
|
final currentKeys = _libraryOrderKeys();
|
|
if (listEquals(currentKeys, _lastSeenLibraryOrderKeys)) return;
|
|
_lastSeenLibraryOrderKeys = currentKeys;
|
|
if (_hubs.isEmpty) return;
|
|
|
|
final sortedHubs = List<MediaHub>.from(_hubs);
|
|
if (!sortMediaHubsByLibraryOrder(sortedHubs, _libraries.libraries)) return;
|
|
_replaceHubs(sortedHubs);
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
List<String> _libraryOrderKeys() => [for (final library in _libraries.libraries) library.globalKey];
|
|
|
|
// --- Platform launcher shelf ----------------------------------------------
|
|
|
|
/// Sync Continue Watching to the platform launcher shelf. Rapid updates
|
|
/// coalesce: a sync that arrives while one is in flight queues exactly one
|
|
/// follow-up pass with the latest items.
|
|
Future<void> _syncSystemShelf(List<MediaItem> onDeck) async {
|
|
if (isDisposed) return;
|
|
final owner = profileId;
|
|
if (owner == null) return;
|
|
_pendingSystemShelfItems = List<MediaItem>.unmodifiable(onDeck);
|
|
if (_systemShelfSyncFuture != null) {
|
|
await _systemShelfSyncFuture;
|
|
return;
|
|
}
|
|
|
|
final syncFuture = _drainSystemShelfSyncQueue();
|
|
_systemShelfSyncFuture = syncFuture;
|
|
await syncFuture;
|
|
}
|
|
|
|
Future<void> _drainSystemShelfSyncQueue() async {
|
|
final owner = profileId;
|
|
if (owner == null) return;
|
|
try {
|
|
while (_pendingSystemShelfItems != null) {
|
|
final onDeck = _pendingSystemShelfItems!;
|
|
_pendingSystemShelfItems = null;
|
|
if (isDisposed) return;
|
|
|
|
try {
|
|
final syncOverride = _syncSystemShelfOverride;
|
|
if (syncOverride != null) {
|
|
await syncOverride(owner, onDeck);
|
|
continue;
|
|
}
|
|
final settings = await SettingsService.getInstance();
|
|
if (isDisposed) return;
|
|
final syncableOnDeck = onDeck
|
|
.where((item) {
|
|
final serverId = item.serverId;
|
|
return serverId != null && _multiServer.getClientForServer(ServerId(serverId)) != null;
|
|
})
|
|
.toList(growable: false);
|
|
await SystemShelfService().syncFromContinueWatching(
|
|
owner,
|
|
syncableOnDeck,
|
|
_clientForShelfItem,
|
|
hideSpoilers: settings.read(SettingsService.hideSpoilers),
|
|
);
|
|
} catch (e) {
|
|
appLogger.w('Failed to sync system shelf', error: e);
|
|
}
|
|
}
|
|
} finally {
|
|
if (!isDisposed) _systemShelfSyncFuture = null;
|
|
}
|
|
}
|
|
|
|
MediaServerClient _clientForShelfItem(ServerId serverId) {
|
|
final direct = _multiServer.getClientForServer(serverId);
|
|
if (direct != null) return direct;
|
|
throw Exception('No owning client available for $serverId');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_multiServer.removeOnlineServersListener(syncToOnlineServers);
|
|
_hiddenLibraries.removeListener(_onHiddenLibrariesChanged);
|
|
_libraries.removeListener(_onLibrariesChanged);
|
|
_watchStateSubscription?.cancel();
|
|
_watchStateSubscription = null;
|
|
_deletionSubscription?.cancel();
|
|
_deletionSubscription = null;
|
|
_loadCoordinator.dispose();
|
|
_pendingSystemShelfItems = null;
|
|
super.dispose();
|
|
}
|
|
}
|