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
+168
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:drift/native.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/media/ids.dart';
@@ -14,6 +15,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/mixins/refreshable.dart';
@@ -240,6 +242,122 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
});
testWidgets('startup prime does not duplicate the load DiscoverScreen started in initState', (tester) async {
// DiscoverScreen.initState fires load(); main_screen._primeOnlineServices
// then primes the content tabs once libraries land. Asking for a full
// refresh there queued a trailing pass through CoalescedLoadCoordinator and
// ran the whole home fan-out twice on every cold start (#1784).
await SettingsService.getInstance();
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: const [], size: 0);
final client = _GatedHubsFakeClient(hubs: [hub]);
// Disposes both the provider and the manager it wraps; MultiServerProvider
// does not own the manager, and manager.dispose() is what closes its
// status/progress controllers and the registered clients.
final multiServerProvider = testMultiServer(clients: [client]).provider;
final hiddenLibrariesProvider = HiddenLibrariesProvider();
final librariesProvider = LibrariesProvider();
final watchTogetherProvider = WatchTogetherProvider();
final companionRemoteProvider = CompanionRemoteProvider();
final db = AppDatabase.forTesting(NativeDatabase.memory());
final connectionRegistry = _FakeConnectionRegistry(db);
final profileConnectionRegistry = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfileProvider = ActiveProfileProvider(
registry: _FakeProfileRegistry(db),
plexHome: plexHome,
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
);
final discoverProvider = DiscoverProvider(
multiServerProvider,
hiddenLibrariesProvider,
librariesProvider,
profileId: null,
isProfileBinding: () => false,
);
addTearDown(() async {
discoverProvider.dispose();
activeProfileProvider.dispose();
companionRemoteProvider.dispose();
watchTogetherProvider.dispose();
librariesProvider.dispose();
hiddenLibrariesProvider.dispose();
// multiServerProvider + its manager are torn down by testMultiServer.
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
ChangeNotifierProvider<DiscoverProvider>.value(value: discoverProvider),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
foregroundWidth: 1280,
viewportWidth: 1280,
child: const SizedBox(width: 1280, height: 720, child: DiscoverScreen()),
),
),
),
),
);
await tester.pump();
// The initState pass is in flight, waiting on the server.
expect(discoverProvider.isLoadInFlight, isTrue);
expect(client.hubCalls, 1);
final screen = tester.state(find.byType(DiscoverScreen)) as FullRefreshable;
screen.primeRefresh();
client.release();
// Bounded pumps, not pumpAndSettle: the hero carousel runs a repeating
// auto-scroll timer, so the frame loop never goes quiet.
await tester.pump();
await tester.pump();
await tester.pump();
expect(client.hubCalls, 1, reason: 'the prime rode along with the load already running');
expect(discoverProvider.isLoadInFlight, isFalse);
// A prime with nothing in flight (reconnect-from-offline) must still refetch.
screen.primeRefresh();
await tester.pump();
client.release();
await tester.pump();
await tester.pump();
expect(client.hubCalls, 2);
});
testWidgets('TV selects Continue Watching when it arrives after recommendation hubs', (tester) async {
await SettingsService.getInstance();
@@ -540,6 +658,56 @@ class _FakeMediaServerClient implements MediaServerClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Hub client that holds each fetch open until [release], so a test can act
/// while a Discover load pass is genuinely in flight.
class _GatedHubsFakeClient implements MediaServerClient {
_GatedHubsFakeClient({required this.hubs});
final List<MediaHub> hubs;
int hubCalls = 0;
final _gates = <Completer<List<MediaHub>>>[];
void release() {
for (final gate in _gates.where((g) => !g.isCompleted)) {
gate.complete(hubs);
}
}
@override
ServerId get serverId => ServerId('server_1');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => const [];
@override
Future<List<MediaLibrary>> fetchLibraries() async => const [];
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) {
hubCalls++;
final gate = Completer<List<MediaHub>>();
_gates.add(gate);
return gate.future;
}
/// Reached by `MultiServerManager.dispose()` via `_closeClientGracefully`.
/// Releases any gate still open so teardown cannot leave a fetch hanging.
@override
void close() => release();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeProfileRegistry extends ProfileRegistry {
_FakeProfileRegistry(super.db);