diff --git a/lib/main.dart b/lib/main.dart index 26c44e70..a03f44fa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -916,19 +916,24 @@ class _MainAppState extends State with WidgetsBindingObserver { // bind in waves on sign-in / profile switch and slow ones // reconnect after the initial load; without this they stay // missing from the sidebar until a re-switch or restart. - context.read().onOnlineServersChanged = provider.syncToOnlineServers; + context.read().addOnlineServersListener(provider.syncToOnlineServers); return provider; }, ), ChangeNotifierProvider( create: (context) { final activeProfile = context.read(); - return DiscoverProvider( - context.read(), + final multiServer = context.read(); + final provider = DiscoverProvider( + multiServer, context.read(), context.read(), isProfileBinding: () => activeProfile.isBinding, ); + // Late server connects (reconnect after outage, slow wave) + // refresh discover the same way they refresh libraries. + multiServer.addOnlineServersListener(provider.syncToOnlineServers); + return provider; }, ), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index aee9988f..45fc13e2 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -73,6 +73,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin Set _lastSeenHiddenKeys = {}; List _lastSeenLibraryOrderKeys = const []; + /// Online servers that contributed to the last successful [load] pass. + Set _loadedOnlineServerIds = {}; + Future? _inFlightLoad; bool _hasPendingLoad = false; @@ -96,6 +99,19 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// a background Continue Watching refresh (clamp only). int get loadGeneration => _loadGeneration; + /// Reload 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. + Future syncToOnlineServers(Set onlineServerIds) { + if (onlineServerIds.isEmpty || isProfileBinding()) return Future.value(); + if (_onDeckState == DiscoverLoadState.loaded && _loadedOnlineServerIds.containsAll(onlineServerIds)) { + return Future.value(); + } + return load(); + } + /// 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). @@ -148,10 +164,13 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin includePlaybackHubs: false, ); + final fetchedFromServerIds = Set.of(_multiServer.onlineServerIds); + final fetchedOnDeck = await onDeckFuture; if (isDisposed) return; _applyOnDeck(fetchedOnDeck); _onDeckState = DiscoverLoadState.loaded; + _loadedOnlineServerIds = fetchedFromServerIds; _loadGeneration++; safeNotifyListeners(); unawaited(_syncSystemShelf(_onDeck)); diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 924ab88e..3a57fc87 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -43,11 +43,19 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Invoked with the current visibility-filtered online server ids whenever /// the manager's status stream fires (a server connects, reconnects, drops, - /// or its auth state changes). Lets `LibrariesProvider` reload when the - /// online set grows — servers bind in waves and slow ones reconnect after - /// the initial load — without coupling the two providers by type. Wired once - /// in `main.dart`. - void Function(Set onlineServerIds)? onOnlineServersChanged; + /// or its auth state changes). Lets data providers (`LibrariesProvider`, + /// `DiscoverProvider`) reload when the online set grows — servers bind in + /// waves and slow ones reconnect after the initial load — without coupling + /// the providers by type. Wired once per consumer in `main.dart`. + final List onlineServerIds)> _onlineServersListeners = []; + + void addOnlineServersListener(void Function(Set onlineServerIds) listener) { + _onlineServersListeners.add(listener); + } + + void removeOnlineServersListener(void Function(Set onlineServerIds) listener) { + _onlineServersListeners.remove(listener); + } /// Visibility filter applied by the active app profile. `null` means /// "all servers visible" (no profile restriction); otherwise only server @@ -148,11 +156,13 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi safeNotifyListeners(); - // Reload libraries when the online set changes. LibrariesProvider owns - // the "is anything actually new to me?" decision (its loaded set can + // Reload data providers when the online set changes. Each listener owns + // the "is anything actually new to me?" decision (their loaded sets can // differ from _previousOnlineServerIds after a load error or a profile - // switch that cleared it), so notify unconditionally and let it decide. - onOnlineServersChanged?.call(currentOnline); + // switch that cleared them), so notify unconditionally and let them decide. + for (final listener in List.of(_onlineServersListeners)) { + listener(currentOnline); + } // Only re-check live TV when a new server came online if (hasNewServer) { diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index edc1c3d0..6d0485ae 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -286,6 +286,26 @@ void main() { expect(provider.onDeck.single.id, 'ep-1'); }); + test('syncToOnlineServers reloads for mid-session connects only', () async { + aggregation.onDeckResult = () => [_item('a')]; + await provider.load(); + final onDeckCallsBefore = aggregation.onDeckCalls; + + // Same server set → already covered, no fetch. + await provider.syncToOnlineServers({'server_1'}); + expect(aggregation.onDeckCalls, onDeckCallsBefore); + + // New server mid-session → full reload. + await provider.syncToOnlineServers({'server_1', 'server_2'}); + expect(aggregation.onDeckCalls, onDeckCallsBefore + 1); + + // During profile binding the startup priming owns loading — waves are + // ignored so the hub fan-out doesn't run once per wave. + isBinding = true; + await provider.syncToOnlineServers({'server_1', 'server_2', 'server_3'}); + expect(aggregation.onDeckCalls, onDeckCallsBefore + 1); + }); + test('loadGeneration bumps on full loads only', () async { aggregation.onDeckResult = () => [_item('a')]; final initial = provider.loadGeneration; diff --git a/test/providers/multi_server_provider_test.dart b/test/providers/multi_server_provider_test.dart index 97d43ed6..8c8c5938 100644 --- a/test/providers/multi_server_provider_test.dart +++ b/test/providers/multi_server_provider_test.dart @@ -86,7 +86,7 @@ void main() { test('invokes onOnlineServersChanged with the visibility-filtered online set', () async { final p = MultiServerProvider(manager, aggregation); final calls = >[]; - p.onOnlineServersChanged = calls.add; + p.addOnlineServersListener(calls.add); manager.updateServerStatus(ServerId('srv-1'), true); await Future.delayed(Duration.zero); @@ -218,7 +218,7 @@ void main() { test('expected servers become visible when they reconnect', () async { final p = MultiServerProvider(manager, aggregation); final onlineCalls = >[]; - p.onOnlineServersChanged = onlineCalls.add; + p.addOnlineServersListener(onlineCalls.add); p.setVisibleServerIds({'srv-1'}); p.setExpectedVisibleServerIds({'srv-1', 'srv-2'});