feat(media-detail): centre the phone hero's logo, chips and actions

On phones the movie/show hero hugged the left edge under a full-bleed
backdrop while the new collection page stacks its poster, title and
actions on the centre line, so the two pages read differently.

At widths under the mobile breakpoint the hero now centres the clear
logo (or title fallback), both chip rows and the action row; the hero
height, chip shedding, focus order and back button are unchanged. Wide
heroes keep the bottom-left column — a 400px logo centred in a
tablet-wide hero floats, and the wide collection header is left-aligned
too. TV is untouched.

_buildDetailLogoOrTitle gains an alignment and _buildDetailTitle a
textAlign, both defaulting to the previous values.
This commit is contained in:
edde746
2026-09-05 08:54:06 +02:00
parent a873009fff
commit b79af75a1f
2 changed files with 69 additions and 9 deletions
+26 -9
View File
@@ -901,6 +901,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
double shadowBlur = 8,
Color? color,
Color? shadowColor,
TextAlign? textAlign,
}) {
final colorScheme = Theme.of(context).colorScheme;
final baseStyle = (Theme.of(context).textTheme.displaySmall ?? const TextStyle()).copyWith(
@@ -910,7 +911,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
shadows: [Shadow(color: shadowColor ?? _detailTitleShadowColor(context), blurRadius: shadowBlur)],
);
return FittingTitleText(title, style: baseStyle);
return FittingTitleText(
title,
style: baseStyle,
textAlign: textAlign,
alignment: textAlign == TextAlign.center ? Alignment.center : Alignment.centerLeft,
);
}
/// Build radial progress indicator for download button
@@ -3854,6 +3860,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
required double width,
required double height,
required Widget Function(BuildContext context, String title) titleBuilder,
Alignment alignment = Alignment.centerLeft,
}) {
Widget titleFallback(BuildContext context) => titleBuilder(context, metadata.displayTitle);
// The hero scrim washes the backdrop toward the scaffold background, so a
@@ -3877,7 +3884,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
context,
artworkPaths: [metadata.clearLogoPath],
fit: BoxFit.contain,
alignment: .centerLeft,
alignment: alignment,
imageType: ImageType.heroLogo,
logoToneTarget: logoToneTarget,
logoToneRemapMixed: false,
@@ -3892,6 +3899,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
width: width,
height: height,
logoToneTarget: logoToneTarget,
alignment: alignment,
fallbackBuilder: titleFallback,
);
},
@@ -4470,18 +4478,25 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
genreBlockHeight +
chipActionGap +
(showActions ? actionHeight : 0.0);
// Phone widths stack the logo, chips and actions on the centre line —
// the collection page's compact header. Wider heroes keep the
// bottom-left column: a 400px logo centred in a tablet-wide hero
// floats, and the wide collection header is left-aligned too.
final centered = constraints.maxWidth < ScreenBreakpoints.mobile;
final blockAlignment = centered ? Alignment.bottomCenter : Alignment.bottomLeft;
final wrapAlignment = centered ? WrapAlignment.center : WrapAlignment.start;
return ClipRect(
child: SizedBox(
height: availableHeight,
child: Align(
alignment: .bottomLeft,
alignment: blockAlignment,
child: SizedBox(
height: contentHeight.clamp(0.0, availableHeight).toDouble(),
child: Align(
alignment: .bottomLeft,
alignment: blockAlignment,
child: Column(
crossAxisAlignment: .start,
crossAxisAlignment: centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
mainAxisSize: .min,
children: [
if (showLogo) ...[
@@ -4490,12 +4505,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
metadata,
width: logoWidth,
height: logoHeight,
alignment: centered ? Alignment.center : Alignment.centerLeft,
titleBuilder: (context, title) => _buildDetailTitle(
context,
title,
fontSize: titleFontSize,
fontWeight: .bold,
shadowBlur: 8,
textAlign: centered ? TextAlign.center : null,
),
),
if (effectiveLogoGap > 0) SizedBox(height: effectiveLogoGap),
@@ -4505,9 +4522,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: chipHeight),
child: Align(
alignment: .bottomLeft,
alignment: blockAlignment,
heightFactor: 1,
child: Wrap(spacing: 8, runSpacing: 8, children: chips),
child: Wrap(spacing: 8, runSpacing: 8, alignment: wrapAlignment, children: chips),
),
),
),
@@ -4517,9 +4534,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: genreRowHeight),
child: Align(
alignment: .bottomLeft,
alignment: blockAlignment,
heightFactor: 1,
child: Wrap(spacing: 8, runSpacing: 8, children: genreChips),
child: Wrap(spacing: 8, runSpacing: 8, alignment: wrapAlignment, children: genreChips),
),
),
),
@@ -1821,6 +1821,49 @@ void main() {
expectChips(present: ['2017', rate], absent: ['1h 46min', '1080p', 'PG-13', '9.2']);
});
testWidgets('phone-width hero centres the title, chip rows and actions; wider heroes stay left', (tester) async {
// The compact hero mirrors the collection page's stacked header; a
// 400px logo centred in a tablet-wide hero would float, so the wide
// hero keeps its bottom-left column.
final movie = testMediaItem(
id: 'centered_movie',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Centered Movie',
year: 2017,
genres: const ['Drama'],
serverId: 'server_1',
serverName: 'Server',
);
final client = _FakeMediaServerClient(show: movie, childrenByParent: const {});
await pumpPhoneDetail(tester, client, movie);
final rate = t.mediaMenu.rate;
Finder strip() => find.ancestor(of: find.text(rate), matching: find.byType(Wrap)).first;
Finder genres() => find.ancestor(of: find.text('Drama'), matching: find.byType(Wrap)).first;
Finder title() => find.byType(FittingTitleText).first;
Finder actions() => find.byType(FocusableActionBar).first;
double centerX(Finder finder) => tester.getCenter(finder).dx;
// 1100 wide: everything hugs the 16px hero inset.
for (final finder in [strip(), genres(), actions()]) {
expect(tester.getTopLeft(finder).dx, moreOrLessEquals(16, epsilon: 1));
}
expect(tester.widget<FittingTitleText>(title()).textAlign, isNull);
tester.view.physicalSize = const Size(420, 2400);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// 420 wide: every row sits on the screen's centre line.
for (final finder in [strip(), genres(), actions()]) {
expect(centerX(finder), moreOrLessEquals(210, epsilon: 1));
}
expect(tester.widget<FittingTitleText>(title()).textAlign, TextAlign.center);
expect(tester.takeException(), isNull);
});
testWidgets('portrait phone hero shows square art instead of the cropped backdrop', (tester) async {
final movie = testMediaItem(
id: 'square_hero',