perf(detail): paint a show before its on-deck episode is looked up

Jellyfin has no equivalent of Plex's bundled `?includeOnDeck=1`, so a show
detail open chained `/Shows/NextUp` behind the item fetch and the screen sat
on a spinner for both round trips. The second one is not needed to paint:
everything except the play button's episode label comes from the item.

`fetchItemWithOnDeck` now takes an `onItemReady` callback and invokes it as
soon as the item is known, when that is strictly before on-deck settles.
Plex returns both together and never invokes it.

Phone and desktop only. TV keeps its own reveal gate — `_isTvDetailReadyToReveal`
holds the foreground at opacity 0 until extras, related hubs, seasons and the
first episode page have all loaded, and those still run after the on-deck
lookup settles, so TV sees no change. Both halves are pinned by tests.

Measured on a remote Jellyfin server, 15 interleaved show-detail opens per
version: time to content 1264ms -> 1042ms (-18%), with the rest of the load
unchanged.

Seasons and extras deliberately still start after the whole lookup settles.
Starting them at the early paint measured worse (time to settled +21%)
because they contend with the on-deck request instead of overlapping it — the
same reason `/Shows/NextUp` is not fired in parallel with the item fetch.
That trade-off is also why TV was left alone rather than being unblocked by
moving those loads earlier.

Two ordering hazards the early paint introduces, both covered by
`media_detail_screen_test.dart`:

- The early call must not write on-deck. `_loadFullMetadata` runs again after
  playback, and clearing there would blank the play button for the length of
  the round trip. `onDeckSettled` marks the authoritative write, so a reload
  that finds the series finished still clears it.
- A settled empty on-deck must not drop the episode-derived fallback that
  `_ensureFallbackOnDeckEpisode` supplies.

close #1784
This commit is contained in:
edde746
2026-08-04 08:38:19 +02:00
parent a759e8b3c6
commit 439ae1d733
5 changed files with 265 additions and 59 deletions
+11 -6
View File
@@ -613,17 +613,22 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
/// OnDeck semantics: returns the resume episode when one exists, or S1E1
/// when the user hasn't started. Movies and other kinds short-circuit.
///
/// Deliberately still chained rather than fired in parallel off a caller
/// kind hint: measured against a remote server that saved nothing, because
/// the two requests contend rather than overlap (`/Shows/NextUp` went from
/// 380ms alone to 1395ms beside the detail fetch), and it would cost a
/// wasted request on every movie whose hint was absent or wrong.
/// The chain stays sequential — firing both together measured no better,
/// because the requests contend rather than overlap (`/Shows/NextUp` went
/// from 380ms alone to 1395ms beside the detail fetch) and a movie would pay
/// for a request it can never use. Instead the item is handed to
/// [onItemReady] the moment it lands, so the caller can paint without
/// waiting for the on-deck round trip (#1784).
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
String id, {
void Function(MediaItem item)? onItemReady,
}) async {
final item = await fetchItem(id);
if (item == null || item.kind != MediaKind.show) {
return (item: item, onDeckEpisode: null);
}
onItemReady?.call(item);
final nextUp = await _safeFetchItemsArray('/Shows/NextUp', {
'seriesId': id,
'userId': connection.userId,
+7 -3
View File
@@ -3769,10 +3769,14 @@ class PlexClient
}
/// Full item with on-deck episode from a single `/library/metadata/{id}`
/// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck];
/// Jellyfin has no analogous endpoint and returns onDeck=null there.
/// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck]. Both
/// halves arrive together, so there is no window in which the item is known
/// and on-deck is not — `onItemReady` is intentionally never invoked.
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(
String id, {
void Function(MediaItem item)? onItemReady,
}) async {
try {
final result = await getMetadataWithImagesAndOnDeck(id, shouldFallback: _shouldFallbackPlexItemLookup);
final itemDto = result['metadata'] as PlexMetadataDto?;