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',