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 succeededServerIds, required Set failedServerIds, required Set 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 Function(String profileId, List)? syncSystemShelf, // A private field cannot be a named initializing formal callers can pass. // ignore: prefer_initializing_formals }) : _watchStateStore = watchStateStore, _syncSystemShelfOverride = syncSystemShelf { _loadCoordinator = CoalescedLoadCoordinator(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( notifier: WatchStateNotifier(), mounted: () => !isDisposed, serverId: () => null, globalKeys: () => _watchedGlobalKeys, itemIds: () => _watchedIds, onEvent: _onWatchStateChanged, ); _deletionSubscription = subscribeToHierarchicalEvents( 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 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 Function(String profileId, List)? _syncSystemShelfOverride; StreamSubscription? _watchStateSubscription; StreamSubscription? _deletionSubscription; List _onDeck = []; List _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? _continueWatchingRefreshFuture; bool _continueWatchingRefreshQueued = false; Set _lastSeenHiddenKeys = {}; List _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 _loadedOnDeckServerIds = {}; /// Online servers whose home-hub legs all succeeded in the current hub list. Set _loadedHubServerIds = {}; Set get _fullyLoadedServerIds => _loadedOnDeckServerIds.intersection(_loadedHubServerIds); late final CoalescedLoadCoordinator _loadCoordinator; Future? _systemShelfSyncFuture; List? _pendingSystemShelfItems; List get onDeck => _onDeck; List 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 syncToOnlineServers(Set onlineServerIds) { if (isDisposed || onlineServerIds.isEmpty || isProfileBinding()) return Future.value(); if (_onDeckState == DiscoverLoadState.loaded && _hubsState == DiscoverLoadState.loaded && _fullyLoadedServerIds.containsAll(onlineServerIds)) { return Future.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 load() { if (isDisposed) return Future.value(); return _loadCoordinator.requestFull(); } Future 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 _loadOnce() async { var outcome = DiscoverRefreshOutcome.cancelled; var passClearedExceptionBoundary = false; List? 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 = {}; // 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 = {}; final failedServerIds = {}; final cancelledServerIds = {}; 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.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.of(previousLoadedOnDeckServerIds); _loadedHubServerIds = Set.of(previousLoadedHubServerIds); _onDeckState = previousOnDeckState; _hubsState = previousHubsState; _loadGeneration = previousLoadGeneration; } else { _filterCurrentContentForHiddenLibraries(); } final hiddenServerIds = _serverIdsForLibraryKeys(_hiddenLibraries.hiddenLibraryKeys); if (!onDeckFetchCompleted) { _loadedOnDeckServerIds = {}; } else { _loadedOnDeckServerIds = Set.of(_loadedOnDeckServerIds) ..removeAll(failedServerIds) ..removeAll(cancelledServerIds); } if (!hubFetchCompleted) { _loadedHubServerIds = {}; } else { _loadedHubServerIds = Set.of(_loadedHubServerIds) ..removeAll(failedServerIds) ..removeAll(cancelledServerIds); } _loadedOnDeckServerIds = Set.of(_loadedOnDeckServerIds)..removeAll(hiddenServerIds); _loadedHubServerIds = Set.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 _loadDeltaOnce(Set 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 onDeckFuture = onDeckIds.isEmpty ? Future.value() : aggregation.getOnDeckFromAllServers( limit: _continueWatchingProbeLimit, hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys, serverIds: onDeckIds, ); final Future hubsFuture = hubIds.isEmpty ? Future.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 = {}; 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 _filterDiscoverHubs(List 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 _authoritativeSucceededServerIds( Set succeededServerIds, Set failedServerIds, Set cancelledServerIds, ) { return Set.of(succeededServerIds) ..removeAll(failedServerIds) ..removeAll(cancelledServerIds); } List _withoutHiddenLibraries(List items, Set hiddenLibraryKeys) { if (hiddenLibraryKeys.isEmpty) return items; return items.where((item) { final libraryKey = item.libraryGlobalKey; return libraryKey == null || !hiddenLibraryKeys.contains(libraryKey); }).toList(); } List _hubsWithoutHiddenLibraries(List hubs, Set hiddenLibraryKeys) { if (hiddenLibraryKeys.isEmpty) return hubs; final filteredHubs = []; 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 _serverIdsForLibraryKeys(Set libraryKeys) { final serverIds = {}; 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 refreshContinueWatching() { final active = _continueWatchingRefreshFuture; if (active != null) { _continueWatchingRefreshQueued = true; return active; } late final Future refresh; refresh = _runContinueWatchingRefreshes().whenComplete(() { if (identical(_continueWatchingRefreshFuture, refresh)) { _continueWatchingRefreshFuture = null; } }); _continueWatchingRefreshFuture = refresh; return refresh; } Future _runContinueWatchingRefreshes() async { do { _continueWatchingRefreshQueued = false; await _refreshContinueWatchingOnce(); } while (_continueWatchingRefreshQueued && !isDisposed); } Future _refreshContinueWatchingOnce() async { try { if (!_multiServer.hasConnectedServers) return; final revision = _contentRevision; final hiddenKeys = Set.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> 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 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.from(hub.items); newItems[itemIndex] = updatedItem; _replaceHubs(List.of(_hubs)..[i] = hub.copyWith(items: newItems)); } } } void _applyOnDeck(List fetched) { final hasMore = fetched.length > continueWatchingPreviewLimit; _replaceOnDeck(hasMore ? fetched.take(continueWatchingPreviewLimit).toList() : fetched, hasMore: hasMore); } void _replaceOnDeck(List onDeck, {required bool hasMore}) { _onDeck = onDeck; _hasMoreContinueWatching = hasMore; ++_commitRevision; } void _replaceHubs(List 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? get _watchedIds => hierarchicalEventIds(_onDeck); Set? 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 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? get _deletionIds => hierarchicalEventIds(_visibleItems); Set? 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.from(_hubs); if (!sortMediaHubsByLibraryOrder(sortedHubs, _libraries.libraries)) return; _replaceHubs(sortedHubs); safeNotifyListeners(); } List _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 _syncSystemShelf(List onDeck) async { if (isDisposed) return; final owner = profileId; if (owner == null) return; _pendingSystemShelfItems = List.unmodifiable(onDeck); if (_systemShelfSyncFuture != null) { await _systemShelfSyncFuture; return; } final syncFuture = _drainSystemShelfSyncQueue(); _systemShelfSyncFuture = syncFuture; await syncFuture; } Future _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(); } }