Files
plezy/lib/widgets/tv_spotlight_background.dart
T
edde746 27acbaf435 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.
2026-07-29 06:47:54 +02:00

401 lines
15 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../media/media_server_client.dart';
import '../providers/watch_state_store.dart';
import '../services/device_performance.dart';
import '../utils/content_utils.dart';
import '../utils/formatters.dart';
import '../utils/layout_constants.dart';
import '../utils/media_image_helper.dart';
import '../services/settings_service.dart';
import 'app_icon.dart';
import 'cycling_media_backdrop.dart';
import 'fitting_title_text.dart';
import 'settings_builder.dart';
import 'media_rating_badge.dart';
import 'optimized_media_image.dart' show ClearLogoImage, blurArtwork;
import 'rasterized_gradient.dart';
class TvSpotlightBackground extends StatelessWidget {
final MediaItem? item;
final MediaServerClient? client;
final bool hideSpoilers;
final double contentBottom;
final double? contentTop;
final double? contentLeft;
final VoidCallback? onPrimaryAction;
final Widget? actions;
final bool compact;
final bool showPrimaryAction;
final bool showInfo;
final String? Function(String? artworkPath)? localArtworkPathResolver;
final bool allowNetwork;
/// Optional caller-owned fact appended to the existing metadata line.
final Widget? metadataTrailing;
const TvSpotlightBackground({
super.key,
required this.item,
required this.client,
this.hideSpoilers = false,
this.contentBottom = 360,
this.contentTop,
this.contentLeft,
this.onPrimaryAction,
this.actions,
this.compact = false,
this.showPrimaryAction = true,
this.showInfo = true,
this.localArtworkPathResolver,
this.allowNetwork = true,
this.metadataTrailing,
});
double _scale(BuildContext context) => TvLayoutConstants.scaleOf(context);
@override
Widget build(BuildContext context) {
final media = item;
final bgColor = Theme.of(context).scaffoldBackgroundColor;
// The gradients never differ between spotlight items, so only the artwork
// cross-fades by image paint alpha. Keeping the gradients outside the
// rotating layer avoids full-screen saveLayers on low-end TVs.
final size = MediaQuery.sizeOf(context);
final containerAspect = size.width / size.height;
final fallbackPaths = media == null
? const <String>[]
: <String>[...media.heroArtCandidates(containerAspectRatio: containerAspect), ?media.thumbPath];
return SettingValueBuilder<bool>(
pref: SettingsService.tvCornerSpotlightBackdrop,
builder: (context, cornerBackdrop, _) {
final backdropSize = cornerBackdrop ? Size(size.width * 0.68, size.height * 0.72) : size;
final backdrop = CyclingMediaBackdrop(
mediaKey: media?.globalKey,
imagePaths: media?.heroRotationPaths(containerAspectRatio: containerAspect) ?? const [],
fallbackImagePaths: fallbackPaths,
client: client,
localArtworkPathResolver: localArtworkPathResolver == null ? null : (path) => localArtworkPathResolver!(path),
allowNetwork: allowNetwork,
// Always request at full-screen size: the corner box only crops the
// layout. A mode-dependent size would change the transcode URL and
// cold-start every cached backdrop when the setting is toggled.
width: size.width,
height: size.height,
fallbackColor: media == null ? bgColor : Theme.of(context).colorScheme.surfaceContainerHighest,
);
return Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: cornerBackdrop ? _buildCornerBackdrop(backdropSize, backdrop) : blurArtwork(backdrop),
),
_buildHorizontalScrim(bgColor),
RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent, bgColor.withValues(alpha: 0.96)],
stops: const [0.0, 0.38, 1.0],
),
),
if (media != null && showInfo)
Positioned(
left: contentLeft ?? TvLayoutConstants.horizontalInset,
right: MediaQuery.sizeOf(context).width * 0.43,
top: contentTop,
bottom: contentBottom,
// The info block still cross-fades via AnimatedSwitcher, but its
// saveLayers are bounded to the text region, not the screen.
child: AnimatedSwitcher(
duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
// Expand instead of the default loose centered Stack so the
// info keeps filling the region and bottom-left aligning.
layoutBuilder: (currentChild, previousChildren) =>
Stack(fit: StackFit.expand, children: [...previousChildren, ?currentChild]),
child: KeyedSubtree(
key: ValueKey(media.globalKey),
child: LayoutBuilder(
builder: (context, constraints) {
if (!constraints.hasBoundedHeight || constraints.maxHeight <= 0 || constraints.maxWidth <= 0) {
return Align(alignment: .bottomLeft, child: _buildInfo(context, media));
}
return Align(
alignment: .bottomLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: .bottomLeft,
child: SizedBox(width: constraints.maxWidth, child: _buildInfo(context, media)),
),
);
},
),
),
),
),
],
);
},
);
}
/// Corner spotlight: artwork pinned to the top-right corner, left and
/// bottom edges feathered into the scaffold background so the info block
/// sits on a calm surface instead of the image.
Widget _buildCornerBackdrop(Size backdropSize, Widget backdrop) {
return Align(
alignment: Alignment.topRight,
child: SizedBox(
width: backdropSize.width,
height: backdropSize.height,
child: ShaderMask(
shaderCallback: (rect) =>
const LinearGradient(colors: [Colors.transparent, Colors.white], stops: [0.0, 0.35]).createShader(rect),
blendMode: BlendMode.dstIn,
child: ShaderMask(
shaderCallback: (rect) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.white, Colors.transparent],
stops: [0.0, 0.55, 1.0],
).createShader(rect),
blendMode: BlendMode.dstIn,
child: backdrop,
),
),
),
);
}
Widget _buildHorizontalScrim(Color bgColor) {
return RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent],
stops: const [0.0, 0.56, 1.0],
),
);
}
Widget _buildInfo(BuildContext context, MediaItem media) {
final scale = _scale(context);
final colorScheme = Theme.of(context).colorScheme;
final shouldHideSpoiler = hideSpoilers && media.shouldHideSpoiler;
final summary = shouldHideSpoiler ? null : media.summary;
final title = media.grandparentTitle ?? media.displayTitle;
return Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
_buildLogoOrTitle(context, media, title),
SizedBox(height: _sectionGap(scale)),
_buildMetadataLine(context, media),
if (summary != null && summary.isNotEmpty) ...[
SizedBox(height: _sectionGap(scale)),
Text(
summary,
maxLines: compact ? 3 : 4,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.78),
fontSize: _summaryFontSize(scale),
height: compact ? 1.34 : 1.45,
),
),
] else if (shouldHideSpoiler && media.isEpisode) ...[
SizedBox(height: _sectionGap(scale)),
Text(
media.title ?? '',
maxLines: 2,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
fontSize: _summaryFontSize(scale),
height: compact ? 1.34 : 1.45,
),
),
],
if (showPrimaryAction || actions != null) ...[
SizedBox(height: (compact ? 18 : 26) * scale),
actions ?? _buildPrimaryAction(context, media),
],
],
);
}
Widget _buildLogoOrTitle(BuildContext context, MediaItem media, String title) {
final scale = _scale(context);
final logoPath = media.clearLogoPath;
final logoWidth = _logoWidth(scale);
final logoHeight = _logoHeight(scale);
if (logoPath == null || logoPath.isEmpty) {
return SizedBox(width: logoWidth, height: logoHeight, child: _buildTitle(context, title));
}
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final (logoMemWidth, logoMemHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (logoWidth * dpr).round(),
displayHeight: (logoHeight * dpr).round(),
imageType: ImageType.heroLogo,
);
final localLogoPath = localArtworkPathResolver?.call(logoPath);
if (localLogoPath != null && File(localLogoPath).existsSync()) {
return SizedBox(
width: logoWidth,
height: logoHeight,
child: blurArtwork(
Image(
image: MediaImageHelper.boundedDecode(
FileImage(File(localLogoPath)),
memWidth: logoMemWidth,
memHeight: logoMemHeight,
),
fit: BoxFit.contain,
alignment: .centerLeft,
errorBuilder: (context, error, stackTrace) => _buildTitle(context, title),
),
sigma: 10,
clip: false,
),
);
}
return ClearLogoImage(
client: client,
logoPath: logoPath,
width: logoWidth,
height: logoHeight,
fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
fallbackBuilder: (context) => _buildTitle(context, title),
);
}
Widget _buildTitle(BuildContext context, String title) {
final scale = _scale(context);
final colorScheme = Theme.of(context).colorScheme;
return FittingTitleText(
title,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: colorScheme.onSurface,
fontSize: _titleFontSize(scale),
fontWeight: .w800,
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 12)],
),
);
}
Widget _buildMetadataLine(BuildContext context, MediaItem media) {
final scale = _scale(context);
final colorScheme = Theme.of(context).colorScheme;
final episodeLabel = formatSeasonEpisodeLabel(media.parentIndex, media.index);
final textStyle = TextStyle(
color: colorScheme.onSurface,
fontSize: _metadataFontSize(scale),
fontWeight: .w700,
letterSpacing: 0.1,
);
final children = <Widget>[];
void addSeparator() {
if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle));
}
void addTextPart(String text) {
addSeparator();
children.add(Text(text, maxLines: 1, style: textStyle));
}
void addWidgetPart(Widget widget) {
addSeparator();
children.add(widget);
}
if (media.isEpisode && episodeLabel != null) addTextPart(episodeLabel);
if (media.isMovie) {
addTextPart(t.discover.movie);
} else if (media.isShow) {
addTextPart(t.discover.tvShow);
}
final ratingBadge = MediaRatingBadge.inlineForMedia(
item: media,
foregroundColor: textStyle.color,
iconSize: textStyle.fontSize,
spacing: 4 * scale,
textStyle: textStyle,
);
if (ratingBadge != null) {
addWidgetPart(ratingBadge);
}
if (media.contentRating != null) addTextPart(formatContentRating(media.contentRating!));
if (media.durationMs != null) addTextPart(formatDurationTextual(media.durationMs!));
if (media.isEpisode && media.originallyAvailableAt != null) {
addTextPart(formatFullDate(media.originallyAvailableAt!));
} else if (media.year != null) {
addTextPart(media.year.toString());
}
if (metadataTrailing case final metadata?) addWidgetPart(metadata);
if (children.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
);
}
double _sectionGap(double scale) => (compact ? 10 : 16) * scale;
double _logoWidth(double scale) =>
(compact ? TvLayoutConstants.compactHeroLogoWidth : TvLayoutConstants.heroLogoWidth) * scale;
double _logoHeight(double scale) =>
(compact ? TvLayoutConstants.compactHeroLogoHeight : TvLayoutConstants.heroLogoHeight) * scale;
double _titleFontSize(double scale) => (compact ? 44 : 54) * scale;
double _metadataFontSize(double scale) => (compact ? 16 : 18) * scale;
double _summaryFontSize(double scale) => (compact ? 18 : 20) * scale;
Widget _buildPrimaryAction(BuildContext context, MediaItem media) {
final scale = _scale(context);
media = context.withFreshWatchState(media);
final hasProgress = media.hasActiveProgress;
final minutesLeft = hasProgress && media.durationMs != null && media.viewOffsetMs != null
? ((media.durationMs! - media.viewOffsetMs!) / 60_000).round()
: 0;
return GestureDetector(
onTap: onPrimaryAction,
child: Container(
padding: .symmetric(horizontal: (compact ? 24 : 30) * scale, vertical: (compact ? 12 : 15) * scale),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(32 * scale)),
child: Row(
mainAxisSize: .min,
children: [
AppIcon(Symbols.play_arrow_rounded, fill: 1, size: (compact ? 24 : 28) * scale, color: Colors.black),
SizedBox(width: (compact ? 10 : 12) * scale),
Text(
hasProgress ? t.discover.minutesLeft(minutes: minutesLeft) : t.common.play,
style: TextStyle(color: Colors.black, fontSize: (compact ? 16 : 18) * scale, fontWeight: .w800),
),
],
),
),
);
}
}