feat(explore): surface the catalog data providers already return

Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.

Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.

Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.

Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.

Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
This commit is contained in:
edde746
2026-07-29 06:47:54 +02:00
parent 0ef71e6489
commit 27acbaf435
97 changed files with 12945 additions and 880 deletions
+6 -1
View File
@@ -27,6 +27,7 @@ import '../services/trakt/trakt_client.dart';
import 'seerr_account_provider.dart';
import 'trakt_account_provider.dart';
import 'trackers_provider.dart';
import '../utils/platform_detector.dart';
import '../utils/app_logger.dart';
typedef PlexDiscoverSessionSupplier = Future<PlexDiscoverSession?> Function();
@@ -94,7 +95,11 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie
final PlexDiscoverSessionSupplier? plexSessionSupplier;
final _CatalogSourceBinding<PlexDiscoverSession, PlexCatalogSource> _plex = _CatalogSourceBinding(
(session) => PlexCatalogSource(PlexDiscoverClient(session)),
// Widened hub artwork (`excludeElements=Media` instead of `Media,Image`)
// costs +104.95% — 27,287 to 55,925 bytes for 26 items, uncached, with up
// to six hubs hydrated concurrently. Its only consumer is the TV
// spotlight's logo/banner treatment, so only TV pays for it.
(session) => PlexCatalogSource(PlexDiscoverClient(session), includeImageVariants: PlatformDetector.isTV()),
equals: (previous, next) => previous == next,
);
final _CatalogSourceBinding<TraktClient, TraktCatalogSource> _trakt = _CatalogSourceBinding(TraktCatalogSource.new);
+24 -5
View File
@@ -18,11 +18,20 @@ enum ExploreLoadState { initial, loading, loaded, error }
class ExploreRowHub {
final CatalogRowId? row;
final String? providerHubId;
final CatalogHubStyle? style;
final int? totalResults;
final MediaHub hub;
const ExploreRowHub.catalogRow({required CatalogRowId this.row, required this.hub}) : providerHubId = null;
const ExploreRowHub.catalogRow({required CatalogRowId this.row, required this.hub, this.totalResults})
: providerHubId = null,
style = null;
const ExploreRowHub.providerHub({required String this.providerHubId, required this.hub}) : row = null;
const ExploreRowHub.providerHub({
required String this.providerHubId,
required this.hub,
this.style,
this.totalResults,
}) : row = null;
}
/// Owns the Explore tab's fixed rows and provider-defined hubs, converted to
@@ -96,27 +105,30 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (page.items.isNotEmpty)
ExploreRowHub.catalogRow(
row: row,
totalResults: page.totalResults,
hub: MediaHub(
id: 'explore:${source.id.name}:${row.name}',
identifier: 'explore.${row.name}',
title: rowTitle(row),
type: 'mixed',
items: [for (final item in page.items) item.toMediaItem()],
size: page.items.length,
size: page.totalResults ?? page.items.length,
more: page.hasMore,
),
),
for (final providerHub in _providerHubs)
if (providerHub.page.items.isNotEmpty)
if (_rendersProviderHub(providerHub) && providerHub.page.items.isNotEmpty)
ExploreRowHub.providerHub(
providerHubId: providerHub.id,
style: providerHub.style,
totalResults: providerHub.page.totalResults,
hub: MediaHub(
id: 'explore:${source.id.name}:hub:${providerHub.id}',
identifier: 'explore.hub.${providerHub.id}',
title: providerHub.title,
type: 'mixed',
items: [for (final item in providerHub.page.items) item.toMediaItem()],
size: providerHub.page.items.length,
size: providerHub.page.totalResults ?? providerHub.page.items.length,
more: providerHub.page.hasMore,
),
),
@@ -126,6 +138,13 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return hubs;
}
static bool _rendersProviderHub(CatalogHub hub) {
// Plex's availabilityPlatforms entries are streaming services, not
// titles. Until Explore has a platform-specific row, skipping the hub is
// preferable to presenting service logos as movie posters.
return hub.style != CatalogHubStyle.availabilityPlatforms;
}
static String rowTitle(CatalogRowId row) => switch (row) {
CatalogRowId.watchlist => t.explore.rows.watchlist,
CatalogRowId.recommendedMovies => t.explore.rows.recommendedMovies,