diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 25cd170dd..e344aaab7 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -110,6 +110,19 @@ part 'media_detail/playback_tracks_status.dart'; /// where an unbounded box would leave artwork showing under the overview. const double _maxHeroArtViewportFraction = 0.86; +/// Height of the sticky top strip that hosts the circular back button; the +/// hero content must start below it (see [_buildHeroBackdropLayer]'s sibling +/// fade bar, sized `padding.top + 58`). +const double _heroChromeHeight = 58; + +/// Full-size logo/title slot in the non-TV hero, the gap under it, the action +/// row height and the hero's bottom inset. Shared by the hero's height floor +/// and its content budget so the two cannot disagree. +const double _heroLogoHeight = 120; +const double _heroLogoGap = 12; +const double _heroActionHeight = 48; +const double _heroBottomInset = 16; + const double _tvDetailTallPosterScale = 0.72; const double _tvDetailEpisodeThumbnailScale = 0.72; const double _tvDetailActionSize = 46; @@ -213,6 +226,35 @@ class _SeasonEpisodePager { @visibleForTesting const tvDetailRevealGateKey = ValueKey('tvDetailRevealGate'); +/// Geometry of the non-TV hero's chip rows. Every chip — plain text, the +/// scores pill, the tappable rating chip — sizes its content to +/// [chipContentHeight] so icons and text never make one chip taller than its +/// neighbours. TV renders a different metadata line and keeps its own metrics. +abstract final class _HeroChips { + /// Gap between chips, and between the two chip rows. + static const double spacing = 4; + static const double paddingH = 10; + static const double paddingV = 5; + + /// Height of a chip's content box: fits the 13px label and a 16px icon. + static const double contentHeight = 20; + static const double height = contentHeight + paddingV * 2; + + /// Gap between the last chip row and the action buttons; shorter heroes + /// (under 180px of content) use the compact value. + static const double actionGap = 10; + static const double shortActionGap = 6; + + static const EdgeInsets padding = EdgeInsets.symmetric(horizontal: paddingH, vertical: paddingV); + static const BorderRadius radius = BorderRadius.all(Radius.circular(100)); + + /// Constrains chip content to one shared height, centred. + static Widget content(Widget child) => SizedBox( + height: contentHeight, + child: Center(widthFactor: 1, child: child), + ); +} + class MediaDetailScreen extends StatefulWidget { final MediaItem metadata; final bool isOffline; @@ -960,25 +1002,27 @@ class _MediaDetailScreenState extends State final hasLeading = leading != null || icon != null; + final content = hasLeading + ? Row( + mainAxisSize: .min, + children: [ + if (leading != null) + leading + else + AppIcon(icon!, fill: 1, color: colorScheme.onSecondaryContainer, size: isTv ? 20 : 16), + SizedBox(width: isTv ? 6 : 4), + textWidget, + ], + ) + : textWidget; + return Container( - padding: .symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6), + padding: isTv ? const EdgeInsets.symmetric(horizontal: 14, vertical: 8) : _HeroChips.padding, decoration: BoxDecoration( color: colorScheme.secondaryContainer.withValues(alpha: 0.8), - borderRadius: const BorderRadius.all(Radius.circular(100)), + borderRadius: _HeroChips.radius, ), - child: hasLeading - ? Row( - mainAxisSize: .min, - children: [ - if (leading != null) - leading - else - AppIcon(icon!, fill: 1, color: colorScheme.onSecondaryContainer, size: isTv ? 20 : 16), - SizedBox(width: isTv ? 6 : 4), - textWidget, - ], - ) - : textWidget, + child: isTv ? content : _HeroChips.content(content), ); } @@ -1003,8 +1047,8 @@ class _MediaDetailScreenState extends State // bare chip style would drop the theme's font metrics. final ambientStyle = DefaultTextStyle.of(context).style; final chipTextStyle = TextStyle(color: colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: .w600); - const chipPadding = 24.0; // _buildMetadataChip: horizontal 12 each side - const chipSpacing = 8.0; // the strip Wrap's spacing + const chipPadding = _HeroChips.paddingH * 2; // _buildMetadataChip: horizontal padding each side + const chipSpacing = _HeroChips.spacing; // the strip Wrap's spacing const iconSize = 16.0; const iconGap = 4.0; const badgeEntryGap = 10.0; @@ -1063,18 +1107,20 @@ class _MediaDetailScreenState extends State ), ], build: (kept) => Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: _HeroChips.padding, decoration: BoxDecoration( color: colorScheme.secondaryContainer.withValues(alpha: 0.8), - borderRadius: const BorderRadius.all(Radius.circular(100)), + borderRadius: _HeroChips.radius, ), - child: InlineRatingBadges( - ratings: kept == ratings.length ? ratings : ratings.sublist(0, kept), - textStyle: chipTextStyle, - foregroundColor: colorScheme.onSecondaryContainer, - iconSize: iconSize, - spacing: iconGap, - entrySpacing: badgeEntryGap, + child: _HeroChips.content( + InlineRatingBadges( + ratings: kept == ratings.length ? ratings : ratings.sublist(0, kept), + textStyle: chipTextStyle, + foregroundColor: colorScheme.onSecondaryContainer, + iconSize: iconSize, + spacing: iconGap, + entrySpacing: badgeEntryGap, + ), ), ), )); @@ -1187,23 +1233,25 @@ class _MediaDetailScreenState extends State child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100))), - child: Row( - mainAxisSize: .min, - children: [ - AppIcon( - iconData, - fill: active ? 1 : 0, - color: showFocus ? fgColor : (active ? activeIconColor : fgColor), - size: 16, - ), - const SizedBox(width: 4), - Text( - label, - style: TextStyle(color: fgColor, fontSize: 13, fontWeight: .w500), - ), - ], + padding: _HeroChips.padding, + decoration: BoxDecoration(color: bgColor, borderRadius: _HeroChips.radius), + child: _HeroChips.content( + Row( + mainAxisSize: .min, + children: [ + AppIcon( + iconData, + fill: active ? 1 : 0, + color: showFocus ? fgColor : (active ? activeIconColor : fgColor), + size: 16, + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle(color: fgColor, fontSize: 13, fontWeight: .w500), + ), + ], + ), ), ), ), @@ -3188,9 +3236,24 @@ class _MediaDetailScreenState extends State ); } - // Determine header height based on screen size + // Determine header height based on screen size. The hero is 60% of the + // viewport but never shorter than a full hero needs — status bar and + // back-button strip, the full-size logo, both chip rows and the action + // row. Portrait phones and tablets clear that floor easily; a phone in + // landscape does not, and without it the budget shrinks the logo away. final size = MediaQuery.sizeOf(context); - final headerHeight = size.height * (isTv ? 1.0 : 0.6); + final heroFloor = isTv + ? 0.0 + : MediaQuery.paddingOf(context).top + + _heroChromeHeight + + _heroLogoHeight + + _heroLogoGap + + _HeroChips.height * 2 + + _HeroChips.spacing + + _HeroChips.actionGap + + _heroActionHeight + + _heroBottomInset; + final headerHeight = isTv ? size.height : math.max(size.height * 0.6, heroFloor); if (isTv) { return _buildTvDetailScreen(context, metadata, _handleMediaDetailBackKey); @@ -3226,153 +3289,166 @@ class _MediaDetailScreenState extends State child: SizedBox(height: headerHeight, child: _buildHeroHeader(context, metadata)), ), - // Main content - SliverToBoxAdapter( - child: Padding( - // 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, - children: [ - // Summary - if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[ - Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle), - const SizedBox(height: 12), - CollapsibleText( - text: metadata.summary!, - maxLines: isMobile ? 6 : 4, - style: theme.textTheme.bodyLarge?.copyWith(height: 1.6), - focusNode: _overviewFocusNode, - skipTraversal: false, - onNavigateUp: () { - _scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - _playButtonFocusNode.requestFocus(); - }, - onNavigateDown: _focusBelowOverview, - onNavigateLeft: () {}, - onNavigateRight: () {}, - ), - const SizedBox(height: 12), - ], + // Main content. The side insets add the horizontal safe + // area on top of the 16px margin so landscape phones keep + // the text clear of the cutout, like the hero above. + SliverSafeArea( + top: false, + bottom: false, + sliver: SliverToBoxAdapter( + child: Padding( + // 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, + children: [ + // Summary + if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[ + Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle), + const SizedBox(height: 12), + CollapsibleText( + text: metadata.summary!, + maxLines: isMobile ? 6 : 4, + style: theme.textTheme.bodyLarge?.copyWith(height: 1.6), + focusNode: _overviewFocusNode, + skipTraversal: false, + onNavigateUp: () { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + _playButtonFocusNode.requestFocus(); + }, + onNavigateDown: _focusBelowOverview, + onNavigateLeft: () {}, + onNavigateRight: () {}, + ), + const SizedBox(height: 12), + ], - // Seasons / Episodes (for TV shows and seasons) - if (isShow && !_showEpisodesDirectly) ...[ - // Season tabs + inline episodes - if (_isLoadingSeasons) - _sectionLoading - else if (_seasonsLoadFailed) - _sectionError(t.messages.seasonsLoadFailed, () => unawaited(_loadSeasons())) - else if (_seasons.isEmpty) - _sectionEmpty(context, t.messages.noSeasonsFound) - else ...[ + // Seasons / Episodes (for TV shows and seasons) + if (isShow && !_showEpisodesDirectly) ...[ + // Season tabs + inline episodes + if (_isLoadingSeasons) + _sectionLoading + else if (_seasonsLoadFailed) + _sectionError(t.messages.seasonsLoadFailed, () => unawaited(_loadSeasons())) + else if (_seasons.isEmpty) + _sectionEmpty(context, t.messages.noSeasonsFound) + else ...[ + Text( + key: _seasonsSectionKey, + t.libraries.groupings.episodes, + style: sectionTitleStyle, + ), + const SizedBox(height: 12), + _buildSeasonTabs(), + const SizedBox(height: 16), + if (_isLoadingSeasonEpisodes) + _sectionLoading + else if (_seasonEpisodesFirstPageError && _episodes.isEmpty) + _sectionError( + t.messages.episodesLoadFailed, + () => unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex)), + ) + else if (_episodes.isNotEmpty) + _buildEpisodesList() + else + _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), + ], + SizedBox(height: isTv ? 24 : 12), + ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ + // Server says flatten — existing behavior unchanged Text( key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle, ), const SizedBox(height: 12), - _buildSeasonTabs(), - const SizedBox(height: 16), - if (_isLoadingSeasonEpisodes) + if (_isLoadingSeasons || _isLoadingEpisodes) _sectionLoading - else if (_seasonEpisodesFirstPageError && _episodes.isEmpty) - _sectionError( - t.messages.episodesLoadFailed, - () => unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex)), - ) + else if (_allEpisodesPageError && _episodes.isEmpty) + _sectionError(t.messages.episodesLoadFailed, () => unawaited(_fetchAllEpisodes())) else if (_episodes.isNotEmpty) _buildEpisodesList() else _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), + SizedBox(height: isTv ? 24 : 12), ], - SizedBox(height: isTv ? 24 : 12), - ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ - // Server says flatten — existing behavior unchanged - Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle), - const SizedBox(height: 12), - if (_isLoadingSeasons || _isLoadingEpisodes) - _sectionLoading - else if (_allEpisodesPageError && _episodes.isEmpty) - _sectionError(t.messages.episodesLoadFailed, () => unawaited(_fetchAllEpisodes())) - else if (_episodes.isNotEmpty) - _buildEpisodesList() - else - _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), - SizedBox(height: isTv ? 24 : 12), - ], - // Cast - if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[ - Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle), - const SizedBox(height: 12), - _buildCastSection(metadata), - SizedBox(height: isTv ? 24 : 12), - ], + // Cast + if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[ + Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle), + const SizedBox(height: 12), + _buildCastSection(metadata), + SizedBox(height: isTv ? 24 : 12), + ], - // Trailers & Extras Section - if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ - Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle), - const SizedBox(height: 12), - _buildExtrasSection(), - SizedBox(height: isTv ? 24 : 12), - ], + // Trailers & Extras Section + if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ + Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle), + const SizedBox(height: 12), + _buildExtrasSection(), + SizedBox(height: isTv ? 24 : 12), + ], - // Related Hubs (Collections, Similar, More From...) - for (int i = 0; i < _relatedHubs.length; i++) ...[ - HubSection( - key: _relatedHubKeys[i], - hub: _relatedHubs[i], - focusMemory: _hubFocusMemory, - icon: _getRelatedHubIcon(_relatedHubs[i]), - inset: true, - onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp), - ), - // 8 on mobile: an inset HubSection already carries ~2px of internal - // bottom padding and the next section ~2px on top, so 8 lands on the - // same ~12px rhythm as the sections above. - SizedBox(height: isTv ? 28 : 8), - ], - - // Additional info — wrapped in Focus so DPAD DOWN from the - // last focusable section lands here and scrolls it into view. - if (_hasInfoRows) - Focus( - focusNode: _infoRowsFocusNode, - onKeyEvent: _handleInfoRowsKeyEvent, - child: Column( - key: _infoRowsSectionKey, - crossAxisAlignment: .start, - children: [ - if (metadata.studio != null) ...[ - _buildInfoRow(t.discover.studio, metadata.studio!), - const SizedBox(height: 12), - ], - if (metadata.directors?.isNotEmpty == true) ...[ - _buildInfoRow( - metadata.directors!.length > 1 ? t.discover.directors : t.discover.director, - metadata.directors!.join(', '), - ), - const SizedBox(height: 12), - ], - if (metadata.contentRating != null) ...[ - _buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)), - const SizedBox(height: 12), - ], - ], + // Related Hubs (Collections, Similar, More From...) + for (int i = 0; i < _relatedHubs.length; i++) ...[ + HubSection( + key: _relatedHubKeys[i], + hub: _relatedHubs[i], + focusMemory: _hubFocusMemory, + icon: _getRelatedHubIcon(_relatedHubs[i]), + inset: true, + onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp), ), - ), - ], + // 8 on mobile: an inset HubSection already carries ~2px of internal + // bottom padding and the next section ~2px on top, so 8 lands on the + // same ~12px rhythm as the sections above. + SizedBox(height: isTv ? 28 : 8), + ], + + // Additional info — wrapped in Focus so DPAD DOWN from the + // last focusable section lands here and scrolls it into view. + if (_hasInfoRows) + Focus( + focusNode: _infoRowsFocusNode, + onKeyEvent: _handleInfoRowsKeyEvent, + child: Column( + key: _infoRowsSectionKey, + crossAxisAlignment: .start, + children: [ + if (metadata.studio != null) ...[ + _buildInfoRow(t.discover.studio, metadata.studio!), + const SizedBox(height: 12), + ], + if (metadata.directors?.isNotEmpty == true) ...[ + _buildInfoRow( + metadata.directors!.length > 1 ? t.discover.directors : t.discover.director, + metadata.directors!.join(', '), + ), + const SizedBox(height: 12), + ], + if (metadata.contentRating != null) ...[ + _buildInfoRow( + t.discover.rating, + formatContentRating(metadata.contentRating!), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + ], + ), ), ), ), @@ -4422,12 +4498,14 @@ class _MediaDetailScreenState extends State Widget _buildHeroHeader(BuildContext context, MediaItem metadata) { // 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. + // Left/right stay enabled for the landscape notch. The top inset covers + // the status bar plus the back-button strip so the logo can never grow + // up underneath the button on a short hero. return SafeArea( top: false, bottom: false, child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + padding: EdgeInsets.fromLTRB(16, MediaQuery.paddingOf(context).top + _heroChromeHeight, 16, _heroBottomInset), child: _buildHeroHeaderContent(context, metadata), ), ); @@ -4439,9 +4517,9 @@ class _MediaDetailScreenState extends State if (constraints.maxHeight <= 0 || constraints.maxWidth <= 0) return const SizedBox.shrink(); final availableHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : 264.0; - const desiredLogoHeight = 120.0; + const desiredLogoHeight = _heroLogoHeight; const desiredLogoWidth = 400.0; - const actionHeight = 48.0; + const actionHeight = _heroActionHeight; // Fitted to a single run: chips shed by usefulness instead of // wrapping onto a second run the height clip below would hide. final chips = _buildFittedHeroChips(context, metadata, constraints.maxWidth); @@ -4451,13 +4529,15 @@ class _MediaDetailScreenState extends State final showActions = availableHeight >= actionHeight; final remainingAfterActions = availableHeight - (showActions ? actionHeight : 0); final showChips = chips.isNotEmpty && remainingAfterActions >= 88; - const chipHeight = 32.0; + const chipHeight = _HeroChips.height; final chipBlockHeight = showChips ? chipHeight : 0.0; - final chipActionGap = showChips && showActions ? (availableHeight < 180 ? 8.0 : 16.0) : 0.0; + final chipActionGap = showChips && showActions + ? (availableHeight < 180 ? _HeroChips.shortActionGap : _HeroChips.actionGap) + : 0.0; // 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; + const genreRowHeight = _HeroChips.height; + const genreGap = _HeroChips.spacing; final showGenres = showChips && genreChips.isNotEmpty && @@ -4465,7 +4545,7 @@ class _MediaDetailScreenState extends State final genreBlockHeight = showGenres ? genreRowHeight + genreGap : 0.0; final remainingForLogo = remainingAfterActions - chipBlockHeight - chipActionGap - genreBlockHeight; final logoGap = remainingForLogo >= 52 && (showChips || showActions) - ? (availableHeight < 180 ? 8.0 : 12.0) + ? (availableHeight < 180 ? 8.0 : _heroLogoGap) : 0.0; final logoHeight = (remainingForLogo - logoGap).clamp(0.0, desiredLogoHeight).toDouble(); final showLogo = logoHeight >= 24; @@ -4524,7 +4604,12 @@ class _MediaDetailScreenState extends State child: Align( alignment: blockAlignment, heightFactor: 1, - child: Wrap(spacing: 8, runSpacing: 8, alignment: wrapAlignment, children: chips), + child: Wrap( + spacing: _HeroChips.spacing, + runSpacing: _HeroChips.spacing, + alignment: wrapAlignment, + children: chips, + ), ), ), ), @@ -4536,7 +4621,12 @@ class _MediaDetailScreenState extends State child: Align( alignment: blockAlignment, heightFactor: 1, - child: Wrap(spacing: 8, runSpacing: 8, alignment: wrapAlignment, children: genreChips), + child: Wrap( + spacing: _HeroChips.spacing, + runSpacing: _HeroChips.spacing, + alignment: wrapAlignment, + children: genreChips, + ), ), ), ),