Files
plezy/lib/widgets/tv_spotlight_background.dart
T
edde746 3b019c8fe2 fix(artwork): show square background art on portrait heroes
Cycling backdrops reach a fallback path only once every rotating path
has failed to load, but every hero passed the rotation-agnostic backdrop
list as the rotation set and the aspect-ordered candidates as the
fallback. One servable wide backdrop was therefore enough to hide the
square background for good, so phone detail and Discover heroes
cover-fitted a 16:9 backdrop into a portrait box instead of showing the
square image Plex supplies.

Give the rotation set the same aspect-aware preference the candidate
list already has: near-square containers rotate the square background
alone and keep the backdrops behind it as fallbacks.

close #1700
2026-07-28 10:31:18 +02:00

396 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;
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,
});
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 (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),
),
],
),
),
);
}
}