From 7d60875e19b367544e8136169778777bb8580f89 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 19 May 2026 19:30:57 +0200 Subject: [PATCH] fix(tv): refine browse rail layout --- lib/screens/discover_screen.dart | 41 +- lib/screens/libraries/libraries_screen.dart | 53 +- .../tabs/library_recommended_tab.dart | 31 +- lib/screens/main_screen.dart | 2 +- lib/screens/media_detail_screen.dart | 107 ++- .../horizontal_scroll_with_arrows.dart | 19 +- lib/widgets/side_navigation_rail.dart | 21 +- lib/widgets/tv_browse_rail.dart | 785 ++++++++++-------- test/widgets/tv_browse_rail_test.dart | 211 +++++ 9 files changed, 856 insertions(+), 414 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index c43217cd..aa20a708 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1193,12 +1193,13 @@ class _DiscoverScreenState extends State padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ - Text( - t.discover.title, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold), - ), + if (!PlatformDetector.isTV()) + Text( + t.discover.title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold), + ), const Spacer(), Consumer2( builder: (context, watchTogether, companionRemote, _) { @@ -1328,6 +1329,8 @@ class _DiscoverScreenState extends State SettingsService.showServerNameOnHubs, SettingsService.showHeroSection, SettingsService.hideSpoilers, + SettingsService.libraryDensity, + SettingsService.episodePosterMode, ], builder: (context) => _buildContent(context), ); @@ -1483,11 +1486,28 @@ class _DiscoverScreenState extends State final size = MediaQuery.sizeOf(context); final theme = Theme.of(context); final spotlight = _effectiveSpotlightItem; - final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers); + final svc = SettingsService.instanceOrNull!; + final hideSpoilers = svc.read(SettingsService.hideSpoilers); final browseHubs = _tvBrowseHubs; - final spotlightTop = (size.height * 0.1).clamp(96.0, 150.0).toDouble(); - final spotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble(); - final spotlightLeft = (24 * TvLayoutConstants.scaleForSize(size)).clamp(18.0, 40.0).toDouble(); + final scale = TvLayoutConstants.scaleForSize(size); + final railHeight = browseHubs.isEmpty + ? 0.0 + : TvBrowseRailLayout.estimateHeight( + size: size, + hubs: browseHubs, + density: svc.read(SettingsService.libraryDensity), + episodePosterMode: svc.read(SettingsService.episodePosterMode), + tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, + ); + final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble(); + final minimumSpotlightBottom = railHeight + (16 * scale); + final baseSpotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble(); + final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom + ? minimumSpotlightBottom + : baseSpotlightBottom; + final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble(); + final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; + final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); return Material( color: theme.scaffoldBackgroundColor, @@ -1548,6 +1568,7 @@ class _DiscoverScreenState extends State hub.id == 'continue_watching' ? _loadAllContinueWatchingItems() : Future.value(hub.items), onNavigateUp: _focusTopActions, onNavigateToSidebar: _navigateToSidebar, + tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ), ), Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())), diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 06580def..e9eb74e2 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1022,7 +1022,8 @@ class _LibrariesScreenState extends State ? allLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull : null; - final showMobileTabsRow = selectedLibrary != null && !PlatformDetector.shouldUseSideNavigation(context); + final useSideNavigation = PlatformDetector.shouldUseSideNavigation(context); + final showMobileTabsRow = selectedLibrary != null && !useSideNavigation; final currentTabIndex = _visibleTabs.isEmpty ? 0 : tabController.index.clamp(0, _visibleTabs.length - 1).toInt(); final currentTabType = _visibleTabs.isEmpty ? null : _visibleTabs[currentTabIndex]; final useTvRecommendedBackdrop = PlatformDetector.isTV() && currentTabType == LibraryTabType.recommended; @@ -1133,36 +1134,46 @@ class _LibrariesScreenState extends State ), ); } else if (selectedLibrary != null) { - Widget buildTabs() { + Widget buildTab(int index) { + return ClipRect( + child: _buildTabContent( + _visibleTabs[index], + library: selectedLibrary, + isActive: tabController.index == index, + tabIndex: index, + ), + ); + } + + Widget buildTabs({bool activeOnly = false}) { + if (activeOnly) return buildTab(currentTabIndex); + + final children = [for (int i = 0; i < _visibleTabs.length; i++) buildTab(i)]; + return TabBarView( key: ValueKey(_selectedLibraryGlobalKey), controller: tabController, - // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches + // Disable swipe on desktop/TV - trackpad and d-pad scroll actions can trigger accidental tab switches. // See: https://github.com/flutter/flutter/issues/11132 - physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, + physics: useSideNavigation ? const NeverScrollableScrollPhysics() : null, // Wrap each tab in ClipRect so horizontal overflow (e.g. hub rows // with Clip.none) doesn't bleed into adjacent tabs during swipe transitions. - children: [ - for (int i = 0; i < _visibleTabs.length; i++) - ClipRect( - child: _buildTabContent( - _visibleTabs[i], - library: selectedLibrary, - isActive: tabController.index == i, - tabIndex: i, - ), - ), - ], + children: children, ); } if (useTvRecommendedBackdrop) { - body = Stack( - fit: StackFit.expand, - children: [ - buildTabs(), - Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())), - ], + body = Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (_, event) => event.logicalKey.isDpadDirection ? KeyEventResult.handled : KeyEventResult.ignored, + child: Stack( + fit: StackFit.expand, + children: [ + buildTabs(activeOnly: true), + Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())), + ], + ), ); } else { body = NestedScrollView( diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 424ffc6c..aba42ce9 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -256,7 +256,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _buildTvContent(items), + ); } return CustomScrollView( @@ -296,10 +299,27 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState baseSpotlightBottom + ? minimumSpotlightBottom + : baseSpotlightBottom; + final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble(); + final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; + final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); return Material( color: theme.scaffoldBackgroundColor, @@ -310,7 +330,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState builder: (context, alwaysExpanded, _) { final contentLeftPadding = alwaysExpanded ? SideNavigationRailState.expandedWidth - : SideNavigationRailState.collapsedWidth; + : SideNavigationRailState.collapsedWidthForContext(context); return OverlaySheetHost( child: PopScope( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 6840dc49..e1d78a21 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -133,6 +133,8 @@ class _MediaDetailScreenState extends State bool _hasLoadedEpisodes = false; double? _tvDetailPendingRailHeight; double? _tvDetailStableRailHeight; + MediaItem? _tvDetailFocusedEpisode; + bool _tvDetailActionRowHasFocus = false; // Inline season tabs int _selectedSeasonIndex = 0; @@ -2862,15 +2864,16 @@ class _MediaDetailScreenState extends State key: _tvDetailRailKey, hubs: detailHubs, iconForHub: _getTvDetailHubIcon, - onFocusedItemChanged: (_) {}, + onFocusedHubItemChanged: _handleTvDetailFocusedRailItemChanged, onRefresh: (itemId) => unawaited(_refreshItemInPlace(itemId)), onActiveHubChanged: _handleTvDetailHubChanged, onActivateItem: _handleTvDetailRailItemActivated, - onNavigateUp: () => _playButtonFocusNode.requestFocus(), + onNavigateUp: _focusTvDetailActionRow, onBack: _popMediaDetailIfBackNotSuppressed, tallPosterScale: _tvDetailTallPosterScale, initialHubId: _tvDetailInitialHubId(metadata), initialItemId: _tvDetailInitialItemId(metadata), + episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, ), ), ], @@ -2907,12 +2910,7 @@ class _MediaDetailScreenState extends State required double scale, }) { final theme = Theme.of(context); - final shouldHideSpoiler = hideSpoilers && metadata.shouldHideSpoiler; - final summary = shouldHideSpoiler ? null : metadata.summary; - final spoilerText = shouldHideSpoiler && metadata.isEpisode - ? (_tvDetailEpisodePrefix(metadata) ?? metadata.title ?? '') - : null; - final description = summary != null && summary.isNotEmpty ? _tvDetailSummaryText(metadata, summary) : spoilerText; + final description = _tvDetailDescription(metadata, hideSpoilers: hideSpoilers); return LayoutBuilder( builder: (context, constraints) { @@ -3099,6 +3097,51 @@ class _MediaDetailScreenState extends State return '$prefix: $summary'; } + String? _tvDetailDescription(MediaItem metadata, {required bool hideSpoilers}) { + final focusedEpisode = _tvDetailFocusedEpisode; + if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); + + final episodeDescription = _tvDetailItemDescription( + focusedEpisode, + hideSpoilers: hideSpoilers, + showSpoilerFallback: false, + ); + if (episodeDescription != null) return episodeDescription; + + final season = _tvDetailSeasonForEpisode(focusedEpisode, metadata); + final seasonDescription = season == null ? null : _tvDetailItemDescription(season, hideSpoilers: hideSpoilers); + if (seasonDescription != null) return seasonDescription; + + final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); + if (showDescription != null) return showDescription; + + if (hideSpoilers && focusedEpisode.shouldHideSpoiler) { + return _tvDetailEpisodePrefix(focusedEpisode) ?? focusedEpisode.title; + } + return null; + } + + String? _tvDetailItemDescription(MediaItem item, {required bool hideSpoilers, bool showSpoilerFallback = true}) { + final shouldHideSpoiler = hideSpoilers && item.shouldHideSpoiler; + final summary = shouldHideSpoiler ? null : item.summary; + if (summary != null && summary.isNotEmpty) return _tvDetailSummaryText(item, summary); + if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return _tvDetailEpisodePrefix(item) ?? item.title; + return null; + } + + MediaItem? _tvDetailSeasonForEpisode(MediaItem episode, MediaItem metadata) { + for (final season in _seasons) { + if (episode.parentId != null && season.id == episode.parentId) return season; + if (episode.parentIndex != null && season.index == episode.parentIndex) return season; + } + if (metadata.isSeason && + ((episode.parentId != null && metadata.id == episode.parentId) || + (episode.parentIndex != null && metadata.index == episode.parentIndex))) { + return metadata; + } + return null; + } + String? _tvDetailEpisodePrefix(MediaItem metadata) { if (!metadata.isEpisode || metadata.parentIndex == null || metadata.index == null) return null; return 'S${metadata.parentIndex}, E${metadata.index}'; @@ -3111,10 +3154,20 @@ class _MediaDetailScreenState extends State hubs: hubs, density: svc.read(SettingsService.libraryDensity), episodePosterMode: svc.read(SettingsService.episodePosterMode), + episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, tallPosterScale: _tvDetailTallPosterScale, ); } + bool _isTvDetailEpisodeHub(MediaHub hub) { + return hub.id.startsWith(_tvDetailSeasonHubIdPrefix) || hub.id == 'detail_episodes'; + } + + EpisodePosterMode _tvDetailEpisodePosterModeForHub(MediaHub hub) { + if (_isTvDetailEpisodeHub(hub)) return EpisodePosterMode.episodeThumbnail; + return SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); + } + List _tvDetailHubs(MediaItem metadata) { final hubs = []; if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) { @@ -3203,7 +3256,45 @@ class _MediaDetailScreenState extends State return true; } + void _clearTvDetailFocusedEpisode() { + if (_tvDetailFocusedEpisode == null) return; + setStateIfMounted(() { + _tvDetailFocusedEpisode = null; + }); + } + + void _setTvDetailActionRowFocus(bool hasFocus) { + _tvDetailActionRowHasFocus = hasFocus; + if (hasFocus) _clearTvDetailFocusedEpisode(); + } + + void _focusTvDetailActionRow() { + _tvDetailActionRowHasFocus = true; + _clearTvDetailFocusedEpisode(); + _playButtonFocusNode.requestFocus(); + } + + void _handleTvDetailFocusedRailItemChanged(MediaHub hub, MediaItem item) { + if (_tvDetailActionRowHasFocus) { + _clearTvDetailFocusedEpisode(); + return; + } + if (!_isTvDetailEpisodeHub(hub) || !item.isEpisode) { + _clearTvDetailFocusedEpisode(); + return; + } + if (_tvDetailFocusedEpisode?.id == item.id) return; + setStateIfMounted(() { + _tvDetailFocusedEpisode = item; + }); + } + void _handleTvDetailHubChanged(MediaHub hub, int index) { + if (!_isTvDetailEpisodeHub(hub)) { + _clearTvDetailFocusedEpisode(); + return; + } + if (hub.items.isEmpty) _clearTvDetailFocusedEpisode(); if (!hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return; final seasonIndex = int.tryParse(hub.id.substring(_tvDetailSeasonHubIdPrefix.length)); if (seasonIndex == null || seasonIndex < 0 || seasonIndex >= _seasons.length) return; diff --git a/lib/widgets/horizontal_scroll_with_arrows.dart b/lib/widgets/horizontal_scroll_with_arrows.dart index 9449a8f9..57286aac 100644 --- a/lib/widgets/horizontal_scroll_with_arrows.dart +++ b/lib/widgets/horizontal_scroll_with_arrows.dart @@ -26,8 +26,8 @@ class HorizontalScrollWithArrows extends StatefulWidget { } class _HorizontalScrollWithArrowsState extends State { - late final ScrollController _scrollController; - late final bool _ownsController; + late ScrollController _scrollController; + late bool _ownsController; bool _isHovering = false; bool _canScrollLeft = false; bool _canScrollRight = false; @@ -41,6 +41,21 @@ class _HorizontalScrollWithArrowsState extends State WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState()); } + @override + void didUpdateWidget(covariant HorizontalScrollWithArrows oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller == widget.controller) return; + + _scrollController.removeListener(_updateScrollState); + if (_ownsController) { + _scrollController.dispose(); + } + _ownsController = widget.controller == null; + _scrollController = widget.controller ?? ScrollController(); + _scrollController.addListener(_updateScrollState); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState()); + } + @override void dispose() { _scrollController.removeListener(_updateScrollState); diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 804a7030..11e44e89 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -194,9 +194,22 @@ class SideNavigationRailState extends State with MountedSetS bool _isTouchExpanded = false; Timer? _collapseTimer; static const double collapsedWidth = 80.0; + static const double tvCollapsedWidth = 64.0; static const double expandedWidth = 220.0; + static const double _horizontalPadding = 12.0; + static const double _itemHorizontalPadding = 17.0; + static const double _defaultIconSize = 22.0; static const Duration _collapseDelay = Duration(milliseconds: 150); + static double collapsedWidthForContext(BuildContext context) => + PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth; + + static double horizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) { + if (!isCollapsed) return _horizontalPadding; + final centeredPadding = ((collapsedWidthForContext(context) - _defaultIconSize) / 2) - _itemHorizontalPadding; + return centeredPadding.clamp(0.0, _horizontalPadding).toDouble(); + } + static const _kHome = 'home'; static const _kLibraries = 'libraries'; static const _kSearch = 'search'; @@ -511,6 +524,8 @@ class SideNavigationRailState extends State with MountedSetS } final isCollapsed = !_shouldExpand; + final effectiveCollapsedWidth = collapsedWidthForContext(context); + final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed); final hasLiveTv = context.watch().hasLiveTv; // Listen to fullscreen + groupLibrariesByServer setting so the rail @@ -568,7 +583,7 @@ class SideNavigationRailState extends State with MountedSetS child: AnimatedContainer( duration: t.normal, curve: Curves.easeOutCubic, - width: isCollapsed ? collapsedWidth : expandedWidth, + width: isCollapsed ? effectiveCollapsedWidth : expandedWidth, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(color: t.surface), child: IgnorePointer( @@ -583,7 +598,7 @@ class SideNavigationRailState extends State with MountedSetS Expanded( child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), clipBehavior: Clip.hardEdge, children: [ if (widget.isOfflineMode && widget.onReconnect != null) ...[ @@ -676,7 +691,7 @@ class SideNavigationRailState extends State with MountedSetS if (_showFullscreenToggle) Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12), child: _buildFullscreenItem(isCollapsed: isCollapsed), ), ], diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 7b477490..e19aea49 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -55,13 +55,19 @@ class TvBrowseRailLayoutMetrics { } class TvBrowseRailLayout { + static const double compactTallPosterScale = 0.84; + static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size); static double horizontalInsetForScale(double scale) => (24 * scale).clamp(18, 40).toDouble(); - static double selectorWidthForScale(double scale) => (230 * scale).clamp(210, 310).toDouble(); + static double railTopPaddingForScale(double scale) => 12 * scale; - static double selectorGapForScale(double scale) => 14 * scale; + static double railBottomPaddingForScale(double scale) => 8 * scale; + + static double hubStripHeightForScale(double scale) => 44 * scale; + + static double hubStripGapForScale(double scale) => 8 * scale; static bool isPersonHub(MediaHub hub) => hub.type == 'person'; @@ -74,13 +80,12 @@ class TvBrowseRailLayout { required double itemGap, }) { final f = LibraryDensity.factor(density); - final targetWidth = (useWideLayout ? 330 : 205) * scale * (1 + (f * 0.12)); - final minCards = useWideLayout ? 3 : 5; - final maxCards = useWideLayout ? 7 : 12; - final cardCount = (availableWidth / targetWidth).floor().clamp(minCards, maxCards); - final fittedWidth = (availableWidth - horizontalPadding - (itemGap * cardCount)) / cardCount; final minWidth = (useWideLayout ? 280 : 170) * scale; final maxWidth = (useWideLayout ? 420 : 250) * scale; + final targetCards = useWideLayout ? 4.2 - (f * 1.4) : 7.0 - (f * 2.0); + final usableWidth = (availableWidth - horizontalPadding).clamp(1.0, double.infinity).toDouble(); + final gapCount = targetCards > 1 ? targetCards - 1 : 0.0; + final fittedWidth = (usableWidth - (itemGap * gapCount)) / targetCards; return fittedWidth.clamp(minWidth, maxWidth).toDouble(); } @@ -129,37 +134,82 @@ class TvBrowseRailLayout { ); } - static double estimateHeight({ - required Size size, + static double maxActiveRailHeight({ required List hubs, + required double availableWidth, required int density, required EpisodePosterMode episodePosterMode, + EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, + required double scale, double tallPosterScale = 1.0, }) { - if (hubs.isEmpty) return 0; - - final scale = scaleForSize(size); - final availableWidth = - size.width - horizontalInsetForScale(scale) - selectorWidthForScale(scale) - selectorGapForScale(scale); - if (availableWidth <= 0) return 0; - - var activeRailHeight = 0.0; + var maxHeight = 0.0; for (final hub in hubs) { final metrics = metricsForHub( hub: hub, availableWidth: availableWidth, density: density, - episodePosterMode: episodePosterMode, + episodePosterMode: episodePosterModeForHub?.call(hub) ?? episodePosterMode, scale: scale, tallPosterScale: tallPosterScale, ); - if (metrics.height > activeRailHeight) activeRailHeight = metrics.height; + if (metrics.height > maxHeight) maxHeight = metrics.height; } + return maxHeight; + } - final visibleShelfCount = hubs.length < 5 ? hubs.length : 5; - final selectorHeight = (46 * scale * visibleShelfCount) + (4 * scale * (visibleShelfCount - 1).clamp(0, 4)); - final rowHeight = activeRailHeight > selectorHeight ? activeRailHeight : selectorHeight; - return (12 * scale) + rowHeight + (24 * scale); + static double estimatedMaxScrollExtent({ + required MediaHub hub, + required TvBrowseRailLayoutMetrics metrics, + required double viewportWidth, + required double scale, + }) { + final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap); + final moreContentWidth = hub.more ? (132 * scale) + metrics.itemGap : 0.0; + final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth; + return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble(); + } + + static double scrollOffsetForIndex({ + required int index, + required TvBrowseRailLayoutMetrics metrics, + required double viewportWidth, + required double maxScrollExtent, + }) { + final itemExtent = metrics.cardWidth + metrics.itemGap; + final targetCenter = metrics.railEdgePadding + (index * itemExtent) + (itemExtent / 2); + return (targetCenter - (viewportWidth / 2)).clamp(0.0, maxScrollExtent).toDouble(); + } + + static double estimateHeight({ + required Size size, + required List hubs, + required int density, + required EpisodePosterMode episodePosterMode, + EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, + double tallPosterScale = 1.0, + }) { + if (hubs.isEmpty) return 0; + + final scale = scaleForSize(size); + final availableWidth = size.width - horizontalInsetForScale(scale); + if (availableWidth <= 0) return 0; + + final activeRailHeight = maxActiveRailHeight( + hubs: hubs, + availableWidth: availableWidth, + density: density, + episodePosterMode: episodePosterMode, + episodePosterModeForHub: episodePosterModeForHub, + scale: scale, + tallPosterScale: tallPosterScale, + ); + + return railTopPaddingForScale(scale) + + hubStripHeightForScale(scale) + + hubStripGapForScale(scale) + + activeRailHeight + + railBottomPaddingForScale(scale); } } @@ -167,6 +217,7 @@ class TvBrowseRail extends StatefulWidget { final List hubs; final IconData Function(MediaHub hub, int index) iconForHub; final ValueChanged? onFocusedItemChanged; + final void Function(MediaHub hub, MediaItem item)? onFocusedHubItemChanged; final void Function(String)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final bool Function(MediaHub hub)? isContinueWatchingHub; @@ -180,12 +231,14 @@ class TvBrowseRail extends StatefulWidget { final String? initialHubId; final String? initialItemId; final bool autofocus; + final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub; const TvBrowseRail({ super.key, required this.hubs, required this.iconForHub, this.onFocusedItemChanged, + this.onFocusedHubItemChanged, this.onRefresh, this.onRemoveFromContinueWatching, this.isContinueWatchingHub, @@ -199,6 +252,7 @@ class TvBrowseRail extends StatefulWidget { this.initialHubId, this.initialItemId, this.autofocus = false, + this.episodePosterModeForHub, }); @override @@ -209,7 +263,9 @@ class TvBrowseRailState extends State { static const _longPressDuration = Duration(milliseconds: 500); final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); - final ScrollController _scrollController = ScrollController(); + final Map _scrollControllers = {}; + final ScrollController _hubStripController = ScrollController(); + final Map _hubStripKeys = {}; final Map> _mediaCardKeys = {}; int _hubIndex = 0; @@ -221,6 +277,9 @@ class TvBrowseRailState extends State { bool _longPressTriggered = false; bool _hasUserChangedHub = false; bool _hasUserChangedItem = false; + bool _railScrollCorrectionPending = false; + bool _hubStripCanScrollLeft = false; + bool _hubStripCanScrollRight = false; MediaHub? get _activeHub => widget.hubs.isEmpty ? null : widget.hubs[_hubIndex.clamp(0, widget.hubs.length - 1)]; @@ -233,11 +292,14 @@ class TvBrowseRailState extends State { void initState() { super.initState(); _focusNode.addListener(_handleFocusChange); + _hubStripController.addListener(_updateHubStripScrollState); _selectInitialHubIfPossible(); final selectedInitialItem = _selectInitialItemIfPossible(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || widget.hubs.isEmpty) return; if (selectedInitialItem) _scrollToItem(animate: false); + _scrollHubStripToActive(animate: false); + _updateHubStripScrollState(); _notifyActiveHubChanged(); _notifyFocusedItem(); if (widget.autofocus) _focusNode.requestFocus(); @@ -277,6 +339,8 @@ class TvBrowseRailState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (selectedInitialItem) _scrollToItem(animate: false); + _scrollHubStripToActive(animate: false); + _updateHubStripScrollState(); if (!oldWidget.autofocus && widget.autofocus) _focusNode.requestFocus(); if (activeHubChanged) _notifyActiveHubChanged(); _notifyFocusedItem(); @@ -288,7 +352,11 @@ class TvBrowseRailState extends State { _longPressTimer?.cancel(); _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); - _scrollController.dispose(); + _hubStripController.removeListener(_updateHubStripScrollState); + for (final controller in _scrollControllers.values) { + controller.dispose(); + } + _hubStripController.dispose(); super.dispose(); } @@ -304,7 +372,9 @@ class TvBrowseRailState extends State { void _notifyFocusedItem() { final hub = _activeHub; if (hub == null || hub.items.isEmpty || _itemIndex >= hub.items.length) return; - widget.onFocusedItemChanged?.call(hub.items[_itemIndex]); + final item = hub.items[_itemIndex]; + widget.onFocusedItemChanged?.call(item); + widget.onFocusedHubItemChanged?.call(hub, item); } void _notifyActiveHubChanged() { @@ -369,6 +439,8 @@ class TvBrowseRailState extends State { if (backResult != KeyEventResult.ignored) return backResult; } + if (key.isDpadDirection && event is KeyUpEvent) return KeyEventResult.handled; + if (!event.isActionable) return KeyEventResult.ignored; final hub = _activeHub; if (hub == null) return KeyEventResult.ignored; @@ -433,10 +505,65 @@ class TvBrowseRailState extends State { _hubIndex = next; _itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1); _hasUserChangedHub = true; + _railScrollCorrectionPending = true; }); _notifyFocusedItem(); _notifyActiveHubChanged(); - _scrollToItem(animate: false); + _scrollToItemAfterLayout(animate: false, revealRail: true); + _scrollHubStripToActive(); + } + + void _scrollHubStripToActive({bool animate = true}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final key = _hubStripKeys[_hubIndex]; + final context = key?.currentContext; + if (context == null) return; + + unawaited( + Scrollable.ensureVisible( + context, + alignment: 0.35, + duration: animate ? const Duration(milliseconds: 180) : Duration.zero, + curve: Curves.easeOutCubic, + ).then((_) { + if (mounted) _updateHubStripScrollState(); + }), + ); + }); + } + + void _scheduleHubStripScrollStateUpdate() { + WidgetsBinding.instance.addPostFrameCallback((_) => _updateHubStripScrollState()); + } + + void _updateHubStripScrollState() { + if (!mounted) return; + + var canScrollLeft = false; + var canScrollRight = false; + if (_hubStripController.hasClients && _hubStripController.position.hasContentDimensions) { + const edgeTolerance = 0.5; + final position = _hubStripController.position; + canScrollLeft = position.pixels > position.minScrollExtent + edgeTolerance; + canScrollRight = position.pixels < position.maxScrollExtent - edgeTolerance; + } + + if (canScrollLeft == _hubStripCanScrollLeft && canScrollRight == _hubStripCanScrollRight) return; + setState(() { + _hubStripCanScrollLeft = canScrollLeft; + _hubStripCanScrollRight = canScrollRight; + }); + } + + void _setHoveredItem(MediaHub hub, int index) { + if (_activeHub?.id != hub.id || index >= hub.items.length || _itemIndex == index) return; + setState(() { + _itemIndex = index; + _hasUserChangedItem = true; + }); + _rememberFocus(hub); + _notifyFocusedItem(); } void _rememberFocus(MediaHub hub) { @@ -444,8 +571,13 @@ class TvBrowseRailState extends State { } void _scrollToItem({bool animate = true}) { + final hub = _activeHub; + if (hub == null) return; + final controller = _scrollControllers[hub.id]; + if (controller == null) return; + scrollListToIndex( - _scrollController, + controller, _itemIndex, itemExtent: _itemExtent, leadingPadding: _railLeadingPadding, @@ -453,6 +585,39 @@ class TvBrowseRailState extends State { ); } + void _scrollToItemAfterLayout({bool animate = true, bool revealRail = false}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _scrollToItem(animate: animate); + if (revealRail && _railScrollCorrectionPending) { + setState(() => _railScrollCorrectionPending = false); + } + }); + } + + ScrollController _scrollControllerForHub( + MediaHub hub, + TvBrowseRailLayoutMetrics metrics, + double viewportWidth, + double scale, + ) { + return _scrollControllers.putIfAbsent(hub.id, () { + final maxScrollExtent = TvBrowseRailLayout.estimatedMaxScrollExtent( + hub: hub, + metrics: metrics, + viewportWidth: viewportWidth, + scale: scale, + ); + final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex( + index: _itemIndex, + metrics: metrics, + viewportWidth: viewportWidth, + maxScrollExtent: maxScrollExtent, + ); + return ScrollController(initialScrollOffset: initialScrollOffset); + }); + } + GlobalKey _cardKeyFor(MediaHub hub, int itemIndex) { return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey()); } @@ -502,85 +667,6 @@ class TvBrowseRailState extends State { double _horizontalInset(BuildContext context) => TvBrowseRailLayout.horizontalInsetForScale(_scale(context)); - double _selectorWidth(BuildContext context) => TvBrowseRailLayout.selectorWidthForScale(_scale(context)); - - double _selectorGap(BuildContext context) => TvBrowseRailLayout.selectorGapForScale(_scale(context)); - - List _visibleShelfIndices() { - const visibleCount = 5; - if (widget.hubs.length <= visibleCount) return List.generate(widget.hubs.length, (index) => index); - final start = (_hubIndex - 2).clamp(0, widget.hubs.length - visibleCount); - return List.generate(visibleCount, (index) => start + index); - } - - _ShelfTitleParts _shelfTitleParts(String title) { - final titleWords = _titleWords(title); - if (titleWords.length < 3) return _ShelfTitleParts(title: title); - - var bestPrefixLength = 0; - var bestSupport = 0; - for (var prefixLength = 2; prefixLength < titleWords.length; prefixLength++) { - if (_suffixStartsWithPunctuation(titleWords, prefixLength)) continue; - - final support = _prefixSupport(titleWords, prefixLength); - if (support < 2) continue; - if (support > bestSupport || (support == bestSupport && prefixLength > bestPrefixLength)) { - bestSupport = support; - bestPrefixLength = prefixLength; - } - } - - if (bestPrefixLength < 2) return _ShelfTitleParts(title: title); - bestPrefixLength = _preferConnectorBoundary(titleWords, bestPrefixLength, bestSupport); - return _splitTitleAtWord(title, bestPrefixLength); - } - - List _titleWords(String title) => - title.trim().split(RegExp(r'\s+')).where((word) => word.isNotEmpty).toList(); - - int _prefixSupport(List titleWords, int prefixLength) { - var support = 0; - for (final hub in widget.hubs) { - final otherWords = _titleWords(hub.title); - if (_commonPrefixLength(titleWords, otherWords) >= prefixLength) support++; - } - return support; - } - - int _commonPrefixLength(List a, List b) { - final maxLength = a.length < b.length ? a.length : b.length; - var length = 0; - while (length < maxLength && a[length].toLowerCase() == b[length].toLowerCase()) { - length++; - } - return length; - } - - int _preferConnectorBoundary(List titleWords, int prefixLength, int support) { - for (var candidate = prefixLength; candidate >= 2; candidate--) { - if (_prefixSupport(titleWords, candidate) != support) continue; - if (_looksLikeConnector(titleWords[candidate - 1])) return candidate; - } - return prefixLength; - } - - bool _looksLikeConnector(String word) { - final stripped = word.replaceAll(RegExp(r'[^\p{L}]', unicode: true), ''); - return stripped.length <= 5 && stripped.isNotEmpty && stripped == stripped.toLowerCase(); - } - - bool _suffixStartsWithPunctuation(List words, int prefixLength) { - if (prefixLength >= words.length) return true; - return RegExp(r'^[^\p{L}\p{N}]', unicode: true).hasMatch(words[prefixLength]); - } - - _ShelfTitleParts _splitTitleAtWord(String title, int wordCount) { - final matches = RegExp(r'\S+').allMatches(title).toList(); - if (wordCount <= 0 || wordCount >= matches.length) return _ShelfTitleParts(title: title); - final split = matches[wordCount - 1].end; - return _ShelfTitleParts(eyebrow: title.substring(0, split).trim(), title: title.substring(split).trim()); - } - @override Widget build(BuildContext context) { final hub = _activeHub; @@ -589,13 +675,17 @@ class TvBrowseRailState extends State { final theme = Theme.of(context); final scale = _scale(context); final horizontalInset = _horizontalInset(context); - final selectorGap = _selectorGap(context); return Focus( focusNode: _focusNode, onKeyEvent: _handleKeyEvent, child: Container( - padding: EdgeInsets.fromLTRB(horizontalInset, 12 * scale, 0, 24 * scale), + padding: EdgeInsets.fromLTRB( + horizontalInset, + TvBrowseRailLayout.railTopPaddingForScale(scale), + 0, + TvBrowseRailLayout.railBottomPaddingForScale(scale), + ), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -603,201 +693,146 @@ class TvBrowseRailState extends State { colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)], ), ), + child: AnimatedOpacity( + opacity: hasFocus ? 1 : 0.6, + duration: FocusTheme.getAnimationDuration(context), + curve: Curves.easeOutCubic, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHubStrip(context), + SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)), + _buildActiveRail(hub, hasFocus), + ], + ), + ), + ), + ); + } + + Widget _buildHubStrip(BuildContext context) { + final scale = _scale(context); + final height = TvBrowseRailLayout.hubStripHeightForScale(scale); + + return SizedBox( + height: height, + child: ExcludeFocus( child: Row( - crossAxisAlignment: CrossAxisAlignment.end, children: [ - SizedBox(width: _selectorWidth(context), child: _buildShelfSelector(context)), - SizedBox(width: selectorGap), - Expanded(child: _buildActiveRail(hub, hasFocus)), + if (widget.hubs.length > 1) ...[ + _buildHubStripAffordance( + scale: scale, + hasAbove: _hubIndex > 0, + hasBelow: _hubIndex < widget.hubs.length - 1, + ), + SizedBox(width: 8 * scale), + ], + Expanded( + child: NotificationListener( + onNotification: (_) { + _scheduleHubStripScrollStateUpdate(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) { + final fadeStop = bounds.width <= 0 + ? 0.08 + : ((32 * scale) / bounds.width).clamp(0.02, 0.12).toDouble(); + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + _hubStripCanScrollLeft ? Colors.transparent : Colors.white, + Colors.white, + Colors.white, + _hubStripCanScrollRight ? Colors.transparent : Colors.white, + ], + stops: [0, fadeStop, 1 - fadeStop, 1], + ).createShader(bounds); + }, + child: ListView.separated( + controller: _hubStripController, + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + clipBehavior: Clip.hardEdge, + padding: EdgeInsets.only(right: 36 * scale), + itemCount: widget.hubs.length, + separatorBuilder: (context, index) => SizedBox(width: 8 * scale), + itemBuilder: _buildHubStripChip, + ), + ), + ), + ), ], ), ), ); } - Widget _buildShelfSelector(BuildContext context) { + Widget _buildHubStripChip(BuildContext context, int index) { final scale = _scale(context); - final visibleIndices = _visibleShelfIndices(); - final isScrollable = widget.hubs.length > visibleIndices.length; - final hasAbove = isScrollable && visibleIndices.first > 0; - final hasBelow = isScrollable && visibleIndices.last < widget.hubs.length - 1; - final rowHeight = 46 * scale; - final rowGap = 4 * scale; - final viewportHeight = isScrollable - ? (rowHeight * 5) + (rowGap * 4) - : (rowHeight * visibleIndices.length) + (rowGap * (visibleIndices.length - 1).clamp(0, 4)); - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: viewportHeight, - child: Stack( - clipBehavior: Clip.none, - children: [ - _buildFadedShelfRows( - visibleIndices: visibleIndices, - hasAbove: hasAbove, - hasBelow: hasBelow, - rowGap: rowGap, - rowHeight: rowHeight, - scale: scale, - viewportHeight: viewportHeight, - ), - if (hasAbove) _buildSelectorChevron(Symbols.keyboard_arrow_up_rounded, scale, top: 0), - if (hasBelow) _buildSelectorChevron(Symbols.keyboard_arrow_down_rounded, scale, bottom: 0), - ], - ), - ), - ], - ); - } - - Widget _buildFadedShelfRows({ - required List visibleIndices, - required bool hasAbove, - required bool hasBelow, - required double rowGap, - required double rowHeight, - required double scale, - required double viewportHeight, - }) { - final rows = Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (var visibleIndex = 0; visibleIndex < visibleIndices.length; visibleIndex++) - Padding( - padding: EdgeInsets.only(bottom: visibleIndex == visibleIndices.length - 1 ? 0 : rowGap), - child: _buildShelfRow(context, visibleIndices[visibleIndex], scale, rowHeight), - ), - ], - ); - - if (!hasAbove && !hasBelow) return rows; - - final fadeStop = ((68 * scale) / viewportHeight).clamp(0.0, 0.45).toDouble(); - final colors = []; - final stops = []; - if (hasAbove) { - colors.addAll([Colors.transparent, Colors.white]); - stops.addAll([0, fadeStop]); - } else { - colors.add(Colors.white); - stops.add(0); - } - if (hasBelow) { - colors.addAll([Colors.white, Colors.transparent]); - stops.addAll([1 - fadeStop, 1]); - } else { - colors.add(Colors.white); - stops.add(1); - } - - return ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) => LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: colors, - stops: stops, - ).createShader(bounds), - child: rows, - ); - } - - Widget _buildShelfRow(BuildContext context, int index, double scale, double rowHeight) { final colorScheme = Theme.of(context).colorScheme; final isActive = index == _hubIndex; + final hub = widget.hubs[index]; + final primaryColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.62); return AnimatedContainer( + key: _hubStripKeys.putIfAbsent(index, () => GlobalKey()), duration: const Duration(milliseconds: 160), - height: rowHeight, - padding: EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 6 * scale), + curve: Curves.easeOutCubic, + padding: EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 7 * scale), decoration: BoxDecoration( color: isActive ? Colors.white.withValues(alpha: 0.16) : Colors.transparent, borderRadius: BorderRadius.circular(tokens(context).radiusMd), ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ AppIcon( - widget.iconForHub(widget.hubs[index], index), + widget.iconForHub(hub, index), fill: 1, - size: 22 * scale, - color: isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.54), + size: 21 * scale, + color: isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.5), + ), + SizedBox(width: 8 * scale), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 260 * scale), + child: Text( + hub.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: primaryColor, + fontSize: 16 * scale, + height: 1, + fontWeight: isActive ? FontWeight.w800 : FontWeight.w600, + ), + ), ), - SizedBox(width: 12 * scale), - Expanded(child: _buildShelfTitle(context, widget.hubs[index], isActive, scale)), ], ), ); } - Widget _buildSelectorChevron(IconData icon, double scale, {double? top, double? bottom}) { - return Positioned( - left: 0, - right: 0, - top: top, - bottom: bottom, - child: IgnorePointer( - child: Center( - child: AppIcon(icon, fill: 1, size: 18 * scale, color: Colors.white.withValues(alpha: 0.45)), - ), - ), - ); - } - - Widget _buildShelfTitle(BuildContext context, MediaHub hub, bool isActive, double scale) { - final parts = hub.id.startsWith('detail_season_') - ? _ShelfTitleParts(title: hub.title) - : _shelfTitleParts(hub.title); - final colorScheme = Theme.of(context).colorScheme; - final primaryColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.62); - final secondaryColor = isActive - ? Colors.white.withValues(alpha: 0.62) - : colorScheme.onSurface.withValues(alpha: 0.42); - - if (parts.eyebrow == null) { - return Text( - parts.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: primaryColor, - fontSize: 16 * scale, - height: 1.05, - fontWeight: isActive ? FontWeight.w800 : FontWeight.w600, - ), - ); - } - + Widget _buildHubStripAffordance({required double scale, required bool hasAbove, required bool hasBelow}) { + final enabledColor = Colors.white.withValues(alpha: 0.62); + final disabledColor = Colors.white.withValues(alpha: 0.18); return Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - parts.eyebrow!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: secondaryColor, - fontSize: 10.5 * scale, - height: 0.95, - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - ), + AppIcon( + Symbols.keyboard_arrow_up_rounded, + fill: 1, + size: 12 * scale, + color: hasAbove ? enabledColor : disabledColor, ), - SizedBox(height: scale), - Text( - parts.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: primaryColor, - fontSize: 16 * scale, - height: 1, - fontWeight: isActive ? FontWeight.w800 : FontWeight.w600, - ), + AppIcon( + Symbols.keyboard_arrow_down_rounded, + fill: 1, + size: 12 * scale, + color: hasBelow ? enabledColor : disabledColor, ), ], ); @@ -810,7 +845,8 @@ class TvBrowseRailState extends State { builder: (context, constraints) { final svc = SettingsService.instanceOrNull!; final density = svc.read(SettingsService.libraryDensity); - final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final EpisodePosterMode episodePosterMode = + widget.episodePosterModeForHub?.call(hub) ?? svc.read(SettingsService.episodePosterMode); final scale = _scale(context); final metrics = TvBrowseRailLayout.metricsForHub( hub: hub, @@ -820,96 +856,124 @@ class TvBrowseRailState extends State { scale: scale, tallPosterScale: widget.tallPosterScale, ); + final scrollController = _scrollControllerForHub(hub, metrics, constraints.maxWidth, scale); + final maxActiveRailHeight = TvBrowseRailLayout.maxActiveRailHeight( + hubs: widget.hubs, + availableWidth: constraints.maxWidth, + density: density, + episodePosterMode: svc.read(SettingsService.episodePosterMode), + episodePosterModeForHub: widget.episodePosterModeForHub, + scale: scale, + tallPosterScale: widget.tallPosterScale, + ); _railLeadingPadding = metrics.railEdgePadding; _itemExtent = metrics.cardWidth + metrics.itemGap; - return SizedBox( - height: metrics.height, - child: ClipRect( - clipper: _RailClipper( - rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap, - verticalOverflow: metrics.focusExtra, - ), - child: HorizontalScrollWithArrows( - controller: _scrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: EdgeInsets.symmetric(horizontal: metrics.railEdgePadding, vertical: 6 * scale), - itemCount: _totalItemCount(hub), - itemBuilder: (context, index) { - final isFocused = hasFocus && index == _itemIndex; - if (index == hub.items.length) { - return Padding( - padding: EdgeInsets.only(right: metrics.itemGap), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - onTap: () { - setState(() { - _itemIndex = index; - _hasUserChangedItem = true; - }); - _navigateToHubDetail(hub); - }, - child: SizedBox( - width: 132 * scale, - height: metrics.containerHeight - metrics.itemGap, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppIcon(Symbols.arrow_forward_rounded, fill: 1, size: 42 * scale, color: Colors.white), - SizedBox(height: 6 * scale), - Text( - t.common.viewAll, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700), + return Opacity( + opacity: _railScrollCorrectionPending ? 0 : 1, + child: SizedBox( + height: maxActiveRailHeight, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: metrics.height, + child: ClipRect( + clipper: _RailClipper( + rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap, + verticalOverflow: metrics.focusExtra, + ), + child: HorizontalScrollWithArrows( + controller: scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: EdgeInsets.symmetric(horizontal: metrics.railEdgePadding, vertical: 6 * scale), + itemCount: _totalItemCount(hub), + itemBuilder: (context, index) { + final isFocused = hasFocus && index == _itemIndex; + if (index == hub.items.length) { + return Padding( + padding: EdgeInsets.only(right: metrics.itemGap), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () { + setState(() { + _itemIndex = index; + _hasUserChangedItem = true; + }); + _navigateToHubDetail(hub); + }, + child: SizedBox( + width: 132 * scale, + height: metrics.containerHeight - metrics.itemGap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppIcon( + Symbols.arrow_forward_rounded, + fill: 1, + size: 42 * scale, + color: Colors.white, + ), + SizedBox(height: 6 * scale), + Text( + t.common.viewAll, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700), + ), + ], + ), ), - ], - ), - ), - ), - ); - } - - final item = hub.items[index]; - return Padding( - padding: EdgeInsets.only(right: metrics.itemGap), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - onTap: () { - setState(() { - _itemIndex = index; - _hasUserChangedItem = true; - }); - _activateCurrentItem(); - }, - onLongPress: metrics.isPersonHub - ? null - : () => _cardKeyFor(hub, index).currentState?.showContextMenu(), - child: metrics.isPersonHub - ? _buildPersonCard( - context, - item, - cardWidth: metrics.cardWidth, - imageSize: metrics.posterHeight, - scale: scale, - ) - : MediaCard( - key: _cardKeyFor(hub, index), - item: item, - width: metrics.cardWidth, - height: metrics.posterHeight, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - forceGridMode: true, - isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false, - mixedHubContext: metrics.isMixedHub, ), + ); + } + + final item = hub.items[index]; + return Padding( + padding: EdgeInsets.only(right: metrics.itemGap), + child: MouseRegion( + onEnter: (_) => _setHoveredItem(hub, index), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () { + setState(() { + _itemIndex = index; + _hasUserChangedItem = true; + }); + _activateCurrentItem(); + }, + onLongPress: metrics.isPersonHub + ? null + : () => _cardKeyFor(hub, index).currentState?.showContextMenu(), + child: metrics.isPersonHub + ? _buildPersonCard( + context, + item, + cardWidth: metrics.cardWidth, + imageSize: metrics.posterHeight, + scale: scale, + ) + : MediaCard( + key: _cardKeyFor(hub, index), + item: item, + width: metrics.cardWidth, + height: metrics.posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false, + mixedHubContext: metrics.isMixedHub, + episodePosterModeOverride: episodePosterMode, + ), + ), + ), + ); + }, ), - ); - }, + ), + ), ), ), ), @@ -996,10 +1060,3 @@ class _RailClipper extends CustomClipper { return oldClipper.rightOverflow != rightOverflow || oldClipper.verticalOverflow != verticalOverflow; } } - -class _ShelfTitleParts { - final String? eyebrow; - final String title; - - const _ShelfTitleParts({this.eyebrow, required this.title}); -} diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 0340e1b0..dd938619 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/locked_hub_controller.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_item.dart'; @@ -17,9 +19,144 @@ import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + group('TvBrowseRailLayout', () { + test('density changes card width', () { + final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'hub_1', title: 'Movies', type: 'movie', items: [item], size: 1); + + final compact = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1040, + density: LibraryDensity.min, + episodePosterMode: EpisodePosterMode.seriesPoster, + scale: 0.85, + ); + final comfortable = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1040, + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.seriesPoster, + scale: 0.85, + ); + + expect(comfortable.cardWidth, greaterThan(compact.cardWidth)); + expect(comfortable.posterWidth, greaterThan(compact.posterWidth)); + }); + + test('detail episode hubs can force episode thumbnails', () { + final episode = MediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + thumbPath: '/episode-thumb', + grandparentThumbPath: '/show-poster', + ); + final hub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: [episode], size: 1); + + final defaultLayout = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1040, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.seriesPoster, + scale: 0.85, + ); + final forcedLayout = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1040, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: 0.85, + ); + + expect(defaultLayout.useWideLayout, isFalse); + expect(forcedLayout.useWideLayout, isTrue); + expect(forcedLayout.posterHeight, lessThan(defaultLayout.posterHeight)); + }); + + test('estimated rail height is stable across mixed hub heights', () { + final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final episode = MediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + thumbPath: '/episode-thumb', + ); + final posterHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); + final wideHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1); + + const size = Size(1280, 720); + final scale = TvBrowseRailLayout.scaleForSize(size); + final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale); + final posterMetrics = TvBrowseRailLayout.metricsForHub( + hub: posterHub, + availableWidth: availableWidth, + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: scale, + ); + final wideMetrics = TvBrowseRailLayout.metricsForHub( + hub: wideHub, + availableWidth: availableWidth, + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: scale, + ); + + final maxHeight = TvBrowseRailLayout.maxActiveRailHeight( + hubs: [wideHub, posterHub], + availableWidth: availableWidth, + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: scale, + ); + + expect(posterMetrics.height, greaterThan(wideMetrics.height)); + expect(maxHeight, posterMetrics.height); + expect( + TvBrowseRailLayout.estimateHeight( + size: size, + hubs: [wideHub, posterHub], + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + ), + TvBrowseRailLayout.estimateHeight( + size: size, + hubs: [posterHub, wideHub], + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + ), + ); + }); + + test('compact tall poster scale reduces browse rail height', () { + final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); + + const size = Size(1280, 720); + final defaultHeight = TvBrowseRailLayout.estimateHeight( + size: size, + hubs: [hub], + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.seriesPoster, + ); + final compactHeight = TvBrowseRailLayout.estimateHeight( + size: size, + hubs: [hub], + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.seriesPoster, + tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, + ); + + expect(compactHeight, lessThan(defaultHeight)); + }); + }); + setUp(() async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); + HubFocusMemory.clear(); await SettingsService.getInstance(); }); @@ -120,6 +257,72 @@ void main() { expect(focusedItemIds.last, episode2.id); }); + testWidgets('scrolls remembered item after switching hubs', (tester) async { + List movieItems() => List.generate( + 12, + (index) => + MediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'), + ); + List episodeItems() => List.generate( + 12, + (index) => MediaItem( + id: 'episode_$index', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $index', + thumbPath: '/episode_$index', + ), + ); + + final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 12); + final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 12); + final serverManager = MultiServerManager(); + HubFocusMemory.setForHub(episodeHub.id, 5); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 700, + height: 720, + child: TvBrowseRail( + hubs: [movieHub, episodeHub], + autofocus: true, + iconForHub: (_, _) => Icons.tv_rounded, + episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail, + ), + ), + ), + ), + ), + ); + await tester.pump(); + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); + + final position = _activeRailPosition(tester); + final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); + final metrics = TvBrowseRailLayout.metricsForHub( + hub: episodeHub, + availableWidth: position.viewportDimension, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: scale, + ); + final itemExtent = metrics.cardWidth + metrics.itemGap; + final targetCenter = metrics.railEdgePadding + (5 * itemExtent) + (itemExtent / 2); + final expectedOffset = (targetCenter - (position.viewportDimension / 2)).clamp(0.0, position.maxScrollExtent); + + expect(position.pixels, closeTo(expectedOffset, 0.1)); + }); + testWidgets('does not autofocus unless requested', (tester) async { FocusManager.instance.primaryFocus?.unfocus(); @@ -151,3 +354,11 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); }); } + +ScrollPosition _activeRailPosition(WidgetTester tester) { + return tester + .stateList(find.byType(Scrollable)) + .map((state) => state.position) + .where((position) => position.maxScrollExtent > 0) + .reduce((a, b) => a.maxScrollExtent > b.maxScrollExtent ? a : b); +}