feat: show genres on the media detail screen

close #1390
This commit is contained in:
edde746
2026-06-26 07:16:39 +02:00
parent c32806d987
commit 719f317b5d
+69 -7
View File
@@ -3126,9 +3126,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Main content
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(
horizontal: isTv ? TvLayoutConstants.horizontalInset : 16,
vertical: isTv ? 8 : 16,
// Reduced top inset keeps the Overview/first section
// tight under the hero's action row (the hero already
// contributes its own bottom inset above this).
padding: .fromLTRB(
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 4,
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 16,
),
child: Column(
crossAxisAlignment: .start,
@@ -3446,22 +3451,29 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final availableHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : 264.0;
final desiredLogoHeight = 220 * scale;
final minLogoHeight = 72 * scale;
final minLogoHeight = 60 * scale;
final desiredLogoWidth = 790 * scale;
final metadataLineHeight = 22 * scale;
final genreLineHeight = 22 * scale;
final genreGap = 6 * scale;
final logoMetadataGap = 10 * scale;
final summaryGap = 10 * scale;
final summaryGap = 6 * scale;
final summaryFontSize = availableHeight < 260 * scale ? 16.2 * scale : 18 * scale;
final summaryLineHeight = summaryFontSize * 1.35;
final actionHeight = _tvDetailActionSize * scale;
final actionGap = 12 * scale;
final hasDescription = description != null && description.isNotEmpty;
// Genres come from the show/movie, not the focused episode, so the line
// stays stable as episode rows gain focus.
final genres = metadata.genres ?? const <String>[];
final genreBlockHeight = genres.isEmpty ? 0.0 : genreGap + genreLineHeight;
var summaryMaxLines = 0;
var logoHeight = 0.0;
for (var lines = hasDescription ? 3 : 0; lines >= 0; lines--) {
final descriptionHeight = lines > 0 ? summaryGap + (summaryLineHeight * lines) : 0.0;
final reservedHeight = logoMetadataGap + metadataLineHeight + descriptionHeight + actionGap + actionHeight;
final reservedHeight =
logoMetadataGap + metadataLineHeight + genreBlockHeight + descriptionHeight + actionGap + actionHeight;
final remainingForLogo = availableHeight - reservedHeight;
if (remainingForLogo >= minLogoHeight || lines == 0) {
summaryMaxLines = lines;
@@ -3475,6 +3487,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final contentHeight =
(showLogo ? logoHeight + logoMetadataGap : 0) +
metadataLineHeight +
genreBlockHeight +
descriptionHeight +
actionGap +
actionHeight;
@@ -3513,6 +3526,26 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
height: metadataLineHeight,
child: Align(alignment: .centerLeft, child: _buildTvDetailMetadataLine(context, metadata, scale)),
),
if (genres.isNotEmpty) ...[
SizedBox(height: genreGap),
SizedBox(
height: genreLineHeight,
child: Align(
alignment: .centerLeft,
child: Text(
genres.join(''),
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(
color: mutedForegroundColor,
fontSize: 16 * scale,
fontWeight: .w600,
letterSpacing: 0.1,
),
),
),
),
],
if (hasDescription && summaryMaxLines > 0) ...[
SizedBox(height: summaryGap),
SizedBox(
@@ -4106,8 +4139,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
bottom: 16,
left: 0,
right: 0,
// bottom: false — the hero is the top sliver, so the bottom safe-area
// inset would otherwise push the action row far up off the hero edge.
// Left/right stay enabled for the landscape notch.
child: SafeArea(
top: false,
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildHeroHeaderContent(context, metadata),
@@ -4135,13 +4172,24 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
for (final label in buildMediaQualityLabels(metadata)) _buildMetadataChip(label),
..._buildRatingChips(metadata),
];
// Genres render on their own line below the metadata chips.
final genreChips = [for (final genre in metadata.genres ?? const <String>[]) _buildMetadataChip(genre)];
final showActions = availableHeight >= actionHeight;
final remainingAfterActions = availableHeight - (showActions ? actionHeight : 0);
final showChips = chips.isNotEmpty && remainingAfterActions >= 88;
final chipHeight = showChips ? (remainingAfterActions >= 170 ? 68.0 : 32.0) : 0.0;
final chipActionGap = showChips && showActions ? (availableHeight < 180 ? 8.0 : 16.0) : 0.0;
final remainingForLogo = remainingAfterActions - chipHeight - chipActionGap;
// Reserve a dedicated genre row, but only when the logo still keeps room
// afterwards so the title isn't crowded out on short heroes.
const genreRowHeight = 32.0;
const genreGap = 8.0;
final showGenres =
showChips &&
genreChips.isNotEmpty &&
remainingAfterActions - chipHeight - chipActionGap - (genreRowHeight + genreGap) >= 52;
final genreBlockHeight = showGenres ? genreRowHeight + genreGap : 0.0;
final remainingForLogo = remainingAfterActions - chipHeight - chipActionGap - genreBlockHeight;
final logoGap = remainingForLogo >= 52 && (showChips || showActions)
? (availableHeight < 180 ? 8.0 : 12.0)
: 0.0;
@@ -4153,6 +4201,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final contentHeight =
(showLogo ? logoHeight + effectiveLogoGap : 0.0) +
chipHeight +
genreBlockHeight +
chipActionGap +
(showActions ? actionHeight : 0.0);
@@ -4196,6 +4245,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
),
),
),
if (showGenres) ...[
const SizedBox(height: genreGap),
ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: genreRowHeight),
child: Align(
alignment: .bottomLeft,
heightFactor: 1,
child: Wrap(spacing: 8, runSpacing: 8, children: genreChips),
),
),
),
],
if (chipActionGap > 0) SizedBox(height: chipActionGap),
if (showActions) SizedBox(height: actionHeight, child: _buildActionButtons(metadata)),
],