Files
plezy/lib/widgets/tv_spotlight_background.dart
T
edde746 f97ab03111 fix(images): bound artwork decodes on both axes and never fetch unsized originals
The decode clamp was height-only, so ultra-wide originals could decode
far past the display budget, and two small-slot fallbacks skipped
server-side resizing entirely — handing multi-megapixel originals to
the decoder behind tiny slots. The TV spotlight background's offline
branch had no decode bound at all. All artwork now flows through a
shared both-axes bound (ResizeImagePolicy.fit: aspect-preserving,
scale-down only), and the reduced tier gets matching thumb/poster
decode caps.

Ref #1349
2026-07-05 09:10:20 +02:00

587 lines
21 KiB
Dart

import 'dart:io';
import 'package:cached_network_image_ce/cached_network_image.dart';
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 '../services/image_cache_service.dart';
import '../utils/content_utils.dart';
import '../utils/formatters.dart';
import '../utils/layout_constants.dart';
import '../utils/media_image_helper.dart';
import 'app_icon.dart';
import 'fitting_title_text.dart';
import 'media_rating_badge.dart';
import 'optimized_media_image.dart' show 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;
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,
});
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, not widget opacity. The former
// whole-stack AnimatedSwitcher kept two full-screen saveLayers (each with
// a backdrop + two full-screen gradient fills) blending per frame on
// every focus move, which alone saturated low-end TV GPUs while browsing.
return Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: blurArtwork(
_SpotlightArtworkCrossfade(
mediaKey: media?.globalKey,
image: media == null ? null : _artworkProvider(context, media),
duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)),
fallbackColor: Theme.of(context).colorScheme.surfaceContainerHighest,
emptyColor: bgColor,
),
),
),
_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)),
),
);
},
),
),
),
),
],
);
}
/// Resolves the backdrop image provider for [media]; null means "no art"
/// (the crossfade shows [_SpotlightArtworkCrossfade.fallbackColor]).
ImageProvider? _artworkProvider(BuildContext context, MediaItem media) {
final size = MediaQuery.sizeOf(context);
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final containerAspect = size.width / size.height;
final artCandidates = <String?>[
media.heroArt(containerAspectRatio: containerAspect) ??
media.grandparentArtPath ??
media.artPath ??
media.backgroundSquarePath ??
media.thumbPath,
media.grandparentArtPath,
media.artPath,
media.backgroundSquarePath,
media.thumbPath,
];
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (size.width * dpr).round(),
displayHeight: (size.height * dpr).round(),
imageType: ImageType.art,
);
for (final candidate in artCandidates) {
final localPath = localArtworkPathResolver?.call(candidate);
if (localPath != null && File(localPath).existsSync()) {
// Local originals skipped the server transcode entirely, so the
// decode bound is the only thing between a full-resolution art file
// and the GPU on a low-RAM TV.
return MediaImageHelper.boundedDecode(FileImage(File(localPath)), memWidth: memWidth, memHeight: memHeight);
}
}
final artPath = artCandidates.firstWhere((path) => path != null && path.isNotEmpty, orElse: () => null);
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: artPath,
maxWidth: size.width,
maxHeight: size.height,
devicePixelRatio: dpr,
imageType: ImageType.art,
);
if (imageUrl.isEmpty) return null;
final provider = CachedNetworkImageProvider(imageUrl, cacheManager: PlexImageCacheManager.instance);
return MediaImageHelper.boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
}
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 localLogoPath = localArtworkPathResolver?.call(logoPath);
if (localLogoPath != null && File(localLogoPath).existsSync()) {
return SizedBox(
width: logoWidth,
height: logoHeight,
child: blurArtwork(
Image.file(
File(localLogoPath),
fit: BoxFit.contain,
alignment: .centerLeft,
errorBuilder: (context, error, stackTrace) => _buildTitle(context, title),
),
sigma: 10,
clip: false,
),
);
}
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: logoPath,
maxWidth: logoWidth,
maxHeight: logoHeight,
devicePixelRatio: dpr,
imageType: ImageType.logo,
);
if (imageUrl.isEmpty) return _buildTitle(context, title);
return SizedBox(
width: logoWidth,
height: logoHeight,
child: blurArtwork(
CachedNetworkImage(
imageUrl: imageUrl,
cacheManager: PlexImageCacheManager.instance,
fit: BoxFit.contain,
alignment: .centerLeft,
memCacheWidth: (logoWidth * dpr).clamp(200, 1000).round(),
fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
fadeOutDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)),
placeholder: (context, url) => const SizedBox.shrink(),
errorBuilder: (context, error, stackTrace) => _buildTitle(context, title),
),
sigma: 10,
clip: false,
),
);
}
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 (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),
),
],
),
),
);
}
}
/// Cross-fades full-screen backdrop art without saveLayers: the previous
/// image stays fully opaque underneath while the incoming one fades in via
/// `Image.opacity` (paint alpha in RawImage). The fade only starts once the
/// incoming image has a frame, so swaps never flash a placeholder — the old
/// backdrop simply stays until the new one is ready.
class _SpotlightArtworkCrossfade extends StatefulWidget {
const _SpotlightArtworkCrossfade({
required this.mediaKey,
required this.image,
required this.duration,
required this.fallbackColor,
required this.emptyColor,
});
/// Identity of the current spotlight item; fades trigger on changes.
final String? mediaKey;
/// null with a non-null [mediaKey] means "item without art" (fallback box);
/// null with a null [mediaKey] means "no item" (empty box).
final ImageProvider? image;
final Duration duration;
final Color fallbackColor;
final Color emptyColor;
@override
State<_SpotlightArtworkCrossfade> createState() => _SpotlightArtworkCrossfadeState();
}
class _SpotlightArtworkCrossfadeState extends State<_SpotlightArtworkCrossfade> with SingleTickerProviderStateMixin {
late final AnimationController _fade = AnimationController(vsync: this, duration: widget.duration);
late String? _currentKey = widget.mediaKey;
late ImageProvider? _base = widget.image;
late Color _baseColor = widget.mediaKey == null ? widget.emptyColor : widget.fallbackColor;
ImageProvider? _incoming;
bool _incomingIsColor = false;
Color _incomingColor = Colors.transparent;
bool _incomingErrored = false;
bool _incomingHasFrame = false;
bool _fadeStarted = false;
@override
void didUpdateWidget(covariant _SpotlightArtworkCrossfade oldWidget) {
super.didUpdateWidget(oldWidget);
_fade.duration = widget.duration;
if (widget.mediaKey == _currentKey) {
// Same item, possibly a re-resolved provider (size change): update the
// settled base silently — gaplessPlayback covers the swap.
if (widget.image != null && widget.image != _base && _incoming == null && !_incomingIsColor) {
_base = widget.image;
}
return;
}
_currentKey = widget.mediaKey;
final incomingColor = widget.mediaKey == null ? widget.emptyColor : widget.fallbackColor;
if (widget.image != null && widget.image == _base) {
// Same artwork (e.g. episodes sharing show art): nothing to fade.
_dropIncoming();
return;
}
setState(() {
_fade.stop();
_fade.value = 0;
_fadeStarted = false;
_incomingErrored = false;
_incomingHasFrame = false;
if (widget.image != null) {
_incoming = widget.image;
_incomingIsColor = false;
} else {
_incoming = null;
_incomingIsColor = true;
_incomingColor = incomingColor;
_startFade(); // no frame to wait for
}
});
}
@override
void dispose() {
_fade.dispose();
super.dispose();
}
void _startFade() {
if (_fadeStarted) return;
_fadeStarted = true;
_fade.forward().whenComplete(_promoteIncoming);
}
void _promoteIncoming() {
if (!mounted || (_incoming == null && !_incomingIsColor)) return;
setState(() {
if (_incomingIsColor) {
_base = null;
_baseColor = _incomingColor;
} else if (_incomingErrored || !_incomingHasFrame) {
// Never promote a provider that produced no frame: the base would
// re-resolve (and re-fail) it. Settle on the fallback box instead.
_base = null;
_baseColor = widget.fallbackColor;
} else {
_base = _incoming;
}
_dropIncoming();
});
}
void _dropIncoming() {
_incoming = null;
_incomingIsColor = false;
_incomingErrored = false;
_incomingHasFrame = false;
_fadeStarted = false;
_fade.value = 0;
}
Widget _image(ImageProvider provider, {Animation<double>? opacity}) {
final isIncoming = opacity != null;
return Image(
// Keyed by provider so a replaced incoming gets a fresh element — the
// framework never clears a retained error on provider swap, which would
// flash the previous item's failure at the next fade.
key: isIncoming ? ValueKey<ImageProvider>(provider) : null,
image: provider,
fit: BoxFit.cover,
excludeFromSemantics: true,
// Keeps the previous frame on provider promotion instead of flashing.
gaplessPlayback: true,
opacity: opacity,
frameBuilder: !isIncoming
? null
: (context, child, frame, wasSynchronouslyLoaded) {
if (frame != null || wasSynchronouslyLoaded) {
_incomingHasFrame = true;
_startFade();
}
return child;
},
errorBuilder: !isIncoming
// The settled base must show a static fallback: anything riding
// _fade here would flash transparent when the controller resets
// for the next swap.
? (context, error, stackTrace) => ColoredBox(color: widget.fallbackColor)
: (context, error, stackTrace) {
// Broken incoming art: fade a plain box in instead (bounded to
// the error case); promotion then settles on the color, not
// the dead provider.
_incomingErrored = true;
_startFade();
return FadeTransition(
opacity: _fade,
child: ColoredBox(color: widget.fallbackColor),
);
},
);
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (_base != null) _image(_base!) else ColoredBox(color: _baseColor),
if (_incoming != null) _image(_incoming!, opacity: _fade),
if (_incomingIsColor)
AnimatedBuilder(
animation: _fade,
builder: (context, _) =>
ColoredBox(color: _incomingColor.withValues(alpha: _incomingColor.a * _fade.value)),
),
],
);
}
}