perf(home): load the home screen once instead of twice per cold start

The Discover tab fanned out its whole request set twice on every cold
start and replayed slow rows on a shrinking timeout ladder, so a healthy
remote server produced anywhere from 4s to 15s of loading.

Measured against a remote Jellyfin server with four libraries, 24
interleaved cold-start samples per side:

  requests  19 -> 9      payload  219 KB -> 94 KB
  settled   5231ms -> 2502ms median, 13222ms -> 5927ms p95

Four independent causes:

- Retry policy. `Client.send` resolves on response headers, so the
  connect budget covers the server's think time and a slow-but-alive
  query raises `connectionTimeout`. Replaying it made the server re-run
  the query with a shorter budget than the one it just missed; the
  `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after
  23s. Hub surfaces now get one whole-request deadline, retry only
  immediate connection errors, and the deadline bounds the whole call
  including the request still in flight.

- Request shape. `/Items/Latest` groups a TV library by series, so its
  rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost
  a DB count each, per row. Hub rows now ask for `Overview` only; watch
  state survives because Jellyfin derives `UserData.Played` from
  `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp`
  sends `NextUpDateCutoff` to bound the server's series-key scan, and
  `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData`
  and `PremiereDate` leave the browse set: neither is an `ItemFields`
  member, so the server dropped them anyway.

- Fan-out. Per-library hubs ran in batches of three separated by a
  barrier, so one slow library stalled every library behind it. A
  sliding window keeps the same peak concurrency without head-of-line
  blocking. Concurrent `fetchLibraries` calls now share one `/Views`
  instead of racing two identical round trips, Plex's global and music
  hub legs start together, and Jellyfin gets Plex's pool tuning.

- Duplicate pass. `DiscoverScreen.initState` starts a load and the
  online-entry hook asked for a full refresh on top of it, which
  `CoalescedLoadCoordinator` correctly queued as a trailing pass. The
  hook now calls `primeRefresh`, which rides along with a load already
  in flight; profile switches still go through `fullRefresh`.

Refs #1784
This commit is contained in:
edde746
2026-08-04 04:35:06 +02:00
parent 8624c37041
commit 74d3af3ae1
19 changed files with 831 additions and 181 deletions
+16 -5
View File
@@ -520,7 +520,7 @@ class _MainScreenState extends State<MainScreen>
}
if (!mounted) return;
_fullRefreshContentTabs();
_primeContentTabs();
}
/// Single-shot "resume queued downloads once any client is online" rule,
@@ -1634,15 +1634,26 @@ class _MainScreenState extends State<MainScreen>
if (_screenKeys[tab]?.currentState case final T state) fn(state);
}
/// Full-refresh the primary content tabs. Shared by the online-entry hook
/// ([_primeOnlineServices]) and the profile-switch invalidation
/// ([_invalidateAllScreens]), which refresh the same set.
/// Full-refresh the primary content tabs. Used by the profile-switch
/// invalidation ([_invalidateAllScreens]), which must refetch everything for
/// the new identity.
void _fullRefreshContentTabs() {
for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) {
for (final tab in _contentTabs) {
_onScreen<FullRefreshable>(tab, (screen) => screen.fullRefresh());
}
}
/// Online-entry variant used by [_primeOnlineServices] on cold start and on
/// reconnect-from-offline. Screens that already started their own load skip
/// it; see [FullRefreshable.primeRefresh].
void _primeContentTabs() {
for (final tab in _contentTabs) {
_onScreen<FullRefreshable>(tab, (screen) => screen.primeRefresh());
}
}
static const _contentTabs = [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search];
Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) {
final tabs = _getBottomNavigationTabs(context);
final selectedIndex = tabs.indexWhere((tab) => tab.id == _currentTab);