From b79af75a1fcddde398d1cde78bec5cea496e8fde Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:51:01 +0200 Subject: [PATCH] feat(media-detail): centre the phone hero's logo, chips and actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/screens/media_detail_screen.dart | 35 +++++++++++++----- test/screens/media_detail_screen_test.dart | 43 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 86656f278..25cd170dd 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -901,6 +901,7 @@ class _MediaDetailScreenState extends State 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 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 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 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 width: width, height: height, logoToneTarget: logoToneTarget, + alignment: alignment, fallbackBuilder: titleFallback, ); }, @@ -4470,18 +4478,25 @@ class _MediaDetailScreenState extends State 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 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 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 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), ), ), ), diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 10d9da552..0eca8e232 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -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(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(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',