The Top Shelf extension now fetches Continue Watching directly from Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its last foreground Discover pass. The app publishes per-profile server descriptors on every shelf sync (`updateSources`): token-free metadata in the app group, tokens in an app-group-shared keychain item, both wiped by `clear`. On success the extension rewrites the cached payload as the offline fallback; any fetch failure falls back to the previous cache-replay behavior. Poster images are passed as remote URLs, so the extension no longer depends on app-side artwork downloads. Episodes now render season/series poster art (2:3, `.poster` shape) instead of 16:9 episode stills, and labels lead with the S/E marker so long titles no longer hide it behind the focused-item marquee. Shelf schema v3 (Dart, Android, tvOS envelopes bumped together) discards stale wide-art caches instead of letterboxing them into poster slots. close #1474 close #1835
1004 lines
42 KiB
Dart
1004 lines
42 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,
|
|
Future<void> Function(String profileId, List<MediaServerClient> clients)? syncServerSources,
|
|
// A private field cannot be a named initializing formal callers can pass.
|
|
// ignore: prefer_initializing_formals
|
|
}) : _watchStateStore = watchStateStore,
|
|
_syncSystemShelfOverride = syncSystemShelf,
|
|
_syncServerSourcesOverride = syncServerSources {
|
|
_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;
|
|
final Future<void> Function(String profileId, List<MediaServerClient> clients)? _syncServerSourcesOverride;
|
|
|
|
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 {
|
|
// tvOS pulls Continue Watching itself; hand the Top Shelf extension
|
|
// the current online server sources before publishing items.
|
|
final sourcesOverride = _syncServerSourcesOverride;
|
|
final syncOverride = _syncSystemShelfOverride;
|
|
if (sourcesOverride != null) {
|
|
await sourcesOverride(owner, _onlineShelfSourceClients());
|
|
} else if (syncOverride == null) {
|
|
await SystemShelfService().syncServerSources(owner, _onlineShelfSourceClients());
|
|
}
|
|
if (isDisposed) return;
|
|
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');
|
|
}
|
|
|
|
List<MediaServerClient> _onlineShelfSourceClients() =>
|
|
_multiServer.serverManager.onlineClients.values.toList(growable: false);
|
|
|
|
@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();
|
|
}
|
|
}
|