From 9dc3a1a1b1cb250b44651398fd95fda5ff834660 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 21 May 2026 15:53:37 +0200 Subject: [PATCH] feat(tv): improve hub rail experience --- lib/focus/locked_hub_controller.dart | 7 + lib/screens/discover_screen.dart | 4 +- .../tabs/library_recommended_tab.dart | 4 +- lib/screens/media_detail_screen.dart | 58 +- lib/utils/formatters.dart | 5 + lib/widgets/tv_browse_rail.dart | 778 ++++++++++-------- lib/widgets/tv_spotlight_background.dart | 24 +- test/utils/formatters_test.dart | 12 + test/widgets/tv_browse_rail_test.dart | 297 ++++++- 9 files changed, 775 insertions(+), 414 deletions(-) diff --git a/lib/focus/locked_hub_controller.dart b/lib/focus/locked_hub_controller.dart index 6c13aebb..1d4d8247 100644 --- a/lib/focus/locked_hub_controller.dart +++ b/lib/focus/locked_hub_controller.dart @@ -26,6 +26,13 @@ class HubFocusMemory { return _lastColumnHint.clamp(0, itemCount - 1); } + /// Get only this hub's remembered index, without falling back to the global column hint. + static int getForHubOnly(String hubKey, int itemCount, {int fallback = 0}) { + if (itemCount <= 0) return 0; + final remembered = _perHubMemory[hubKey]; + return (remembered ?? fallback).clamp(0, itemCount - 1); + } + /// Clear all memory (e.g., when leaving a screen) static void clear() { _perHubMemory.clear(); diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index aa20a708..d700d264 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1500,8 +1500,8 @@ class _DiscoverScreenState extends State 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 minimumSpotlightBottom = railHeight + (8 * scale); + final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble(); final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom ? minimumSpotlightBottom : baseSpotlightBottom; diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index aba42ce9..351df321 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -312,8 +312,8 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState baseSpotlightBottom ? minimumSpotlightBottom : baseSpotlightBottom; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 7c9640c8..2eaebee3 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -77,9 +77,10 @@ import '../widgets/tv_spotlight_background.dart'; part 'media_detail/action_buttons.dart'; -const double _tvDetailTallPosterScale = 0.84; +const double _tvDetailTallPosterScale = TvBrowseRailLayout.compactTallPosterScale; +const double _tvDetailEpisodeThumbnailScale = TvBrowseRailLayout.compactEpisodeThumbnailScale; const double _tvDetailActionSize = 46; -const double _tvDetailActionRailGap = 8; +const double _tvDetailActionRailGap = 4; const String _tvDetailSeasonHubIdPrefix = 'detail_season_'; const String _tvDetailActorsHubId = 'detail_actors'; const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId'; @@ -667,6 +668,7 @@ class _MediaDetailScreenState extends State _scrollController = ScrollController(); _scrollController.addListener(_onScroll); _extrasFocusNode = FocusNode(debugLabel: 'extras_row'); + _extrasFocusNode.addListener(_handleExtrasFocusChange); _playButtonFocusNode = FocusNode(debugLabel: 'play_button'); _ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip'); _overviewFocusNode = FocusNode(debugLabel: 'overview'); @@ -791,6 +793,7 @@ class _MediaDetailScreenState extends State _scrollController.dispose(); _scrollOffset.dispose(); _extrasScrollController.dispose(); + _extrasFocusNode.removeListener(_handleExtrasFocusChange); _extrasFocusNode.dispose(); _playButtonFocusNode.dispose(); _ratingChipFocusNode.dispose(); @@ -2111,6 +2114,16 @@ class _MediaDetailScreenState extends State return KeyEventResult.ignored; } + void _handleExtrasFocusChange() { + if (!_extrasFocusNode.hasFocus) _resetExtrasLongPressState(); + } + + void _resetExtrasLongPressState() { + _selectKeyTimer?.cancel(); + _isSelectKeyDown = false; + _longPressTriggered = false; + } + /// Handle key events for the cast row (locked focus pattern) KeyEventResult _handleCastKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; @@ -2873,6 +2886,7 @@ class _MediaDetailScreenState extends State onNavigateUp: _focusTvDetailActionRow, onBack: _popMediaDetailIfBackNotSuppressed, tallPosterScale: _tvDetailTallPosterScale, + widePosterScaleForHub: _tvDetailWidePosterScaleForHub, initialHubId: _tvDetailInitialHubId(metadata), initialItemId: _tvDetailInitialItemId(metadata), episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, @@ -3077,12 +3091,18 @@ class _MediaDetailScreenState extends State } Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) { + final lineMetadata = _tvDetailFocusedEpisode ?? metadata; + final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); final parts = [ - if (metadata.isMovie) t.discover.movie else if (metadata.isShow) t.discover.tvShow, - if (metadata.rating != null) '★ ${formatRating(metadata.rating!)}', - if (metadata.contentRating != null) formatContentRating(metadata.contentRating!), - if (metadata.durationMs != null) formatDurationTextual(metadata.durationMs!), - if (metadata.year != null) metadata.year.toString(), + if (lineMetadata.isEpisode && episodeLabel != null) episodeLabel, + if (lineMetadata.isMovie) t.discover.movie else if (lineMetadata.isShow) t.discover.tvShow, + if (lineMetadata.rating != null) '★ ${formatRating(lineMetadata.rating!)}', + if (lineMetadata.contentRating != null) formatContentRating(lineMetadata.contentRating!), + if (lineMetadata.durationMs != null) formatDurationTextual(lineMetadata.durationMs!), + if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) + formatFullDate(lineMetadata.originallyAvailableAt!) + else if (lineMetadata.year != null) + lineMetadata.year.toString(), ]; return Text( @@ -3093,12 +3113,6 @@ class _MediaDetailScreenState extends State ); } - String _tvDetailSummaryText(MediaItem metadata, String summary) { - final prefix = _tvDetailEpisodePrefix(metadata); - if (prefix == null) return summary; - return '$prefix: $summary'; - } - String? _tvDetailDescription(MediaItem metadata, {required bool hideSpoilers}) { final focusedEpisode = _tvDetailFocusedEpisode; if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); @@ -3117,17 +3131,15 @@ class _MediaDetailScreenState extends State final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); if (showDescription != null) return showDescription; - if (hideSpoilers && focusedEpisode.shouldHideSpoiler) { - return _tvDetailEpisodePrefix(focusedEpisode) ?? focusedEpisode.title; - } + if (hideSpoilers && focusedEpisode.shouldHideSpoiler) return 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; + if (summary != null && summary.isNotEmpty) return summary; + if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return item.title; return null; } @@ -3144,11 +3156,6 @@ class _MediaDetailScreenState extends State return null; } - String? _tvDetailEpisodePrefix(MediaItem metadata) { - if (!metadata.isEpisode || metadata.parentIndex == null || metadata.index == null) return null; - return 'S${metadata.parentIndex}, E${metadata.index}'; - } - double _estimateTvBrowseRailHeight(Size size, List hubs) { final svc = SettingsService.instanceOrNull!; return TvBrowseRailLayout.estimateHeight( @@ -3157,6 +3164,7 @@ class _MediaDetailScreenState extends State density: svc.read(SettingsService.libraryDensity), episodePosterMode: svc.read(SettingsService.episodePosterMode), episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, + widePosterScaleForHub: _tvDetailWidePosterScaleForHub, tallPosterScale: _tvDetailTallPosterScale, ); } @@ -3170,6 +3178,10 @@ class _MediaDetailScreenState extends State return SettingsService.instanceOrNull!.read(SettingsService.episodePosterMode); } + double _tvDetailWidePosterScaleForHub(MediaHub hub) { + return _isTvDetailEpisodeHub(hub) ? _tvDetailEpisodeThumbnailScale : 1.0; + } + List _tvDetailHubs(MediaItem metadata) { final hubs = []; if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) { diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index 3b028fe1..c94c6126 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -173,6 +173,11 @@ String toBulletedString(List parts) { return parts.join(' · '); } +String? formatSeasonEpisodeLabel(int? season, int? episode) { + if (season == null || episode == null) return null; + return 'S$season E$episode'; +} + String formatRating(double value) => value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1); diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index e19aea49..5b82b0a7 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -55,7 +55,8 @@ class TvBrowseRailLayoutMetrics { } class TvBrowseRailLayout { - static const double compactTallPosterScale = 0.84; + static const double compactTallPosterScale = 0.80; + static const double compactEpisodeThumbnailScale = compactTallPosterScale; static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size); @@ -65,9 +66,22 @@ class TvBrowseRailLayout { static double railBottomPaddingForScale(double scale) => 8 * scale; - static double hubStripHeightForScale(double scale) => 44 * scale; + static double railInteractionExpansionForScale(double scale) => (12 * scale).clamp(8, 18).toDouble(); - static double hubStripGapForScale(double scale) => 8 * scale; + static double hubStripHeightForScale(double scale) => 36 * scale; + + static double hubStripGapForScale(double scale) => 0; + + static double nextHubPeekHeightForScale(double scale) => 30 * scale; + + static double hubSectionHeightFor({required double scale, required double activeRailHeight}) { + return hubStripHeightForScale(scale) + hubStripGapForScale(scale) + activeRailHeight; + } + + static double viewportHeightFor({required int hubCount, required double scale, required double sectionHeight}) { + final peekHeight = hubCount > 1 ? nextHubPeekHeightForScale(scale) : 0.0; + return sectionHeight + peekHeight; + } static bool isPersonHub(MediaHub hub) => hub.type == 'person'; @@ -96,6 +110,7 @@ class TvBrowseRailLayout { required EpisodePosterMode episodePosterMode, required double scale, double tallPosterScale = 1.0, + double widePosterScale = 1.0, }) { final focusExtra = FocusTheme.focusBorderWidth * 2 * scale; final railEdgePadding = focusExtra + (12 * scale); @@ -113,7 +128,7 @@ class TvBrowseRailLayout { horizontalPadding: railEdgePadding * 2, itemGap: itemGap, ); - final cardWidth = useWideLayout ? baseCardWidth : baseCardWidth * tallPosterScale; + final cardWidth = baseCardWidth * (useWideLayout ? widePosterScale : tallPosterScale); final posterWidth = cardWidth - (6 * scale); final posterHeight = isPersonHub ? posterWidth : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5); final containerHeight = (posterHeight + ((isPersonHub ? 58 : 42) * scale)).ceilToDouble(); @@ -140,8 +155,10 @@ class TvBrowseRailLayout { required int density, required EpisodePosterMode episodePosterMode, EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, + double Function(MediaHub hub)? widePosterScaleForHub, required double scale, double tallPosterScale = 1.0, + double widePosterScale = 1.0, }) { var maxHeight = 0.0; for (final hub in hubs) { @@ -152,6 +169,7 @@ class TvBrowseRailLayout { episodePosterMode: episodePosterModeForHub?.call(hub) ?? episodePosterMode, scale: scale, tallPosterScale: tallPosterScale, + widePosterScale: widePosterScaleForHub?.call(hub) ?? widePosterScale, ); if (metrics.height > maxHeight) maxHeight = metrics.height; } @@ -187,7 +205,9 @@ class TvBrowseRailLayout { required int density, required EpisodePosterMode episodePosterMode, EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, + double Function(MediaHub hub)? widePosterScaleForHub, double tallPosterScale = 1.0, + double widePosterScale = 1.0, }) { if (hubs.isEmpty) return 0; @@ -195,20 +215,22 @@ class TvBrowseRailLayout { final availableWidth = size.width - horizontalInsetForScale(scale); if (availableWidth <= 0) return 0; - final activeRailHeight = maxActiveRailHeight( + final railHeight = maxActiveRailHeight( hubs: hubs, availableWidth: availableWidth, density: density, episodePosterMode: episodePosterMode, episodePosterModeForHub: episodePosterModeForHub, + widePosterScaleForHub: widePosterScaleForHub, scale: scale, tallPosterScale: tallPosterScale, + widePosterScale: widePosterScale, ); + final sectionHeight = hubSectionHeightFor(scale: scale, activeRailHeight: railHeight); + return railTopPaddingForScale(scale) + - hubStripHeightForScale(scale) + - hubStripGapForScale(scale) + - activeRailHeight + + viewportHeightFor(hubCount: hubs.length, scale: scale, sectionHeight: sectionHeight) + railBottomPaddingForScale(scale); } } @@ -228,10 +250,12 @@ class TvBrowseRail extends StatefulWidget { final VoidCallback? onBack; final FutureOr Function(MediaHub hub, MediaItem item)? onActivateItem; final double tallPosterScale; + final double widePosterScale; final String? initialHubId; final String? initialItemId; final bool autofocus; final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub; + final double Function(MediaHub hub)? widePosterScaleForHub; const TvBrowseRail({ super.key, @@ -249,10 +273,12 @@ class TvBrowseRail extends StatefulWidget { this.onBack, this.onActivateItem, this.tallPosterScale = 1.0, + this.widePosterScale = 1.0, this.initialHubId, this.initialItemId, this.autofocus = false, this.episodePosterModeForHub, + this.widePosterScaleForHub, }); @override @@ -264,22 +290,21 @@ class TvBrowseRailState extends State { final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); final Map _scrollControllers = {}; - final ScrollController _hubStripController = ScrollController(); - final Map _hubStripKeys = {}; + final ScrollController _verticalController = ScrollController(); + final Map _hubSectionKeys = {}; final Map> _mediaCardKeys = {}; int _hubIndex = 0; int _itemIndex = 0; double _itemExtent = 260; double _railLeadingPadding = 0; + List _sectionOffsets = const []; + double _sectionMaxScrollExtent = 0; Timer? _longPressTimer; bool _isSelectKeyDown = false; 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)]; @@ -292,14 +317,12 @@ 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(); + _scrollActiveHubToTop(animate: false); _notifyActiveHubChanged(); _notifyFocusedItem(); if (widget.autofocus) _focusNode.requestFocus(); @@ -336,11 +359,11 @@ class TvBrowseRailState extends State { _itemIndex = _itemIndex.clamp(0, _totalItemCount(hub) == 0 ? 0 : _totalItemCount(hub) - 1); final selectedInitialItem = _selectInitialItemIfPossible(); final activeHubChanged = oldActiveHubId != _activeHub?.id; + final shouldAlignActiveHub = selectedInitialHub || activeHubChanged || !_hasUserChangedHub; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (selectedInitialItem) _scrollToItem(animate: false); - _scrollHubStripToActive(animate: false); - _updateHubStripScrollState(); + if (shouldAlignActiveHub) _scrollActiveHubToTop(animate: false); if (!oldWidget.autofocus && widget.autofocus) _focusNode.requestFocus(); if (activeHubChanged) _notifyActiveHubChanged(); _notifyFocusedItem(); @@ -352,19 +375,25 @@ class TvBrowseRailState extends State { _longPressTimer?.cancel(); _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); - _hubStripController.removeListener(_updateHubStripScrollState); for (final controller in _scrollControllers.values) { controller.dispose(); } - _hubStripController.dispose(); + _verticalController.dispose(); super.dispose(); } void _handleFocusChange() { + if (!_focusNode.hasFocus) _resetLongPressState(); if (_focusNode.hasFocus) _notifyFocusedItem(); setState(() {}); } + void _resetLongPressState() { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + _longPressTriggered = false; + } + int _totalItemCount(MediaHub hub) => hub.items.length + (hub.more ? 1 : 0); bool _isPersonHub(MediaHub hub) => TvBrowseRailLayout.isPersonHub(hub); @@ -499,63 +528,55 @@ class TvBrowseRailState extends State { if (widget.hubs.isEmpty) return; final next = (_hubIndex + delta).clamp(0, widget.hubs.length - 1); if (next == _hubIndex) return; + final currentHub = _activeHub; + if (currentHub != null) _rememberFocus(currentHub); final nextHub = widget.hubs[next]; - final remembered = HubFocusMemory.getForHub(nextHub.id, _totalItemCount(nextHub)); + final remembered = HubFocusMemory.getForHubOnly(nextHub.id, _totalItemCount(nextHub)); setState(() { _hubIndex = next; _itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1); _hasUserChangedHub = true; - _railScrollCorrectionPending = true; }); _notifyFocusedItem(); _notifyActiveHubChanged(); - _scrollToItemAfterLayout(animate: false, revealRail: true); - _scrollHubStripToActive(); + _scrollToItemAfterLayout(animate: false); + _scrollActiveHubToTop(); } - void _scrollHubStripToActive({bool animate = true}) { + void _scrollActiveHubToTop({bool animate = true}) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final key = _hubStripKeys[_hubIndex]; + if (_verticalController.hasClients && _hubIndex >= 0 && _hubIndex < _sectionOffsets.length) { + final target = _sectionOffsets[_hubIndex].clamp(0.0, _sectionMaxScrollExtent).toDouble(); + if (animate) { + unawaited( + _verticalController.animateTo( + target, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ), + ); + } else { + _verticalController.jumpTo(target); + } + return; + } + + final key = _hubSectionKeys[_hubIndex]; final context = key?.currentContext; if (context == null) return; unawaited( Scrollable.ensureVisible( context, - alignment: 0.35, - duration: animate ? const Duration(milliseconds: 180) : Duration.zero, + alignment: 0, + duration: animate ? const Duration(milliseconds: 250) : 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(() { @@ -566,6 +587,27 @@ class TvBrowseRailState extends State { _notifyFocusedItem(); } + void _selectHubItem(MediaHub hub, int hubIndex, int itemIndex) { + final totalCount = _totalItemCount(hub); + if (totalCount == 0) return; + + final clampedItemIndex = itemIndex.clamp(0, totalCount - 1).toInt(); + final hubChanged = _hubIndex != hubIndex; + final previousHub = _activeHub; + if (hubChanged && previousHub != null) _rememberFocus(previousHub); + setState(() { + _hubIndex = hubIndex; + _itemIndex = clampedItemIndex; + _hasUserChangedHub = true; + _hasUserChangedItem = true; + }); + _rememberFocus(hub); + _notifyFocusedItem(); + if (hubChanged) _notifyActiveHubChanged(); + _scrollActiveHubToTop(); + _scrollToItemAfterLayout(animate: false); + } + void _rememberFocus(MediaHub hub) { HubFocusMemory.setForHub(hub.id, _itemIndex); } @@ -585,13 +627,10 @@ class TvBrowseRailState extends State { ); } - void _scrollToItemAfterLayout({bool animate = true, bool revealRail = false}) { + void _scrollToItemAfterLayout({bool animate = true}) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _scrollToItem(animate: animate); - if (revealRail && _railScrollCorrectionPending) { - setState(() => _railScrollCorrectionPending = false); - } }); } @@ -600,6 +639,7 @@ class TvBrowseRailState extends State { TvBrowseRailLayoutMetrics metrics, double viewportWidth, double scale, + int initialItemIndex, ) { return _scrollControllers.putIfAbsent(hub.id, () { final maxScrollExtent = TvBrowseRailLayout.estimatedMaxScrollExtent( @@ -609,7 +649,7 @@ class TvBrowseRailState extends State { scale: scale, ); final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex( - index: _itemIndex, + index: initialItemIndex, metrics: metrics, viewportWidth: viewportWidth, maxScrollExtent: maxScrollExtent, @@ -669,308 +709,112 @@ class TvBrowseRailState extends State { @override Widget build(BuildContext context) { - final hub = _activeHub; - if (hub == null) return const SizedBox.shrink(); - final hasFocus = _focusNode.hasFocus; - final theme = Theme.of(context); - final scale = _scale(context); - final horizontalInset = _horizontalInset(context); - - return Focus( - focusNode: _focusNode, - onKeyEvent: _handleKeyEvent, - child: Container( - padding: EdgeInsets.fromLTRB( - horizontalInset, - TvBrowseRailLayout.railTopPaddingForScale(scale), - 0, - TvBrowseRailLayout.railBottomPaddingForScale(scale), - ), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - 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( - children: [ - 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 _buildHubStripChip(BuildContext context, int index) { - final scale = _scale(context); - 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), - 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(hub, index), - fill: 1, - 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, - ), - ), - ), - ], - ), - ); - } - - 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, - children: [ - AppIcon( - Symbols.keyboard_arrow_up_rounded, - fill: 1, - size: 12 * scale, - color: hasAbove ? enabledColor : disabledColor, - ), - AppIcon( - Symbols.keyboard_arrow_down_rounded, - fill: 1, - size: 12 * scale, - color: hasBelow ? enabledColor : disabledColor, - ), - ], - ); - } - - Widget _buildActiveRail(MediaHub hub, bool hasFocus) { + if (_activeHub == null) return const SizedBox.shrink(); return SettingsBuilder( prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode], builder: (context) => LayoutBuilder( builder: (context, constraints) { final svc = SettingsService.instanceOrNull!; - final density = svc.read(SettingsService.libraryDensity); - final EpisodePosterMode episodePosterMode = - widget.episodePosterModeForHub?.call(hub) ?? svc.read(SettingsService.episodePosterMode); + final hasFocus = _focusNode.hasFocus; + final theme = Theme.of(context); final scale = _scale(context); - final metrics = TvBrowseRailLayout.metricsForHub( - hub: hub, - availableWidth: constraints.maxWidth, - density: density, - episodePosterMode: episodePosterMode, - 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; + final horizontalInset = _horizontalInset(context); + final interactionExpansion = TvBrowseRailLayout.railInteractionExpansionForScale( + scale, + ).clamp(0.0, horizontalInset).toDouble(); + final width = constraints.maxWidth.isFinite ? constraints.maxWidth : MediaQuery.sizeOf(context).width; + final availableWidth = (width - horizontalInset).clamp(1.0, double.infinity).toDouble(); + final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble(); + final density = svc.read(SettingsService.libraryDensity); + final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final modes = [for (final hub in widget.hubs) widget.episodePosterModeForHub?.call(hub) ?? episodePosterMode]; + final wideScales = [ + for (final hub in widget.hubs) widget.widePosterScaleForHub?.call(hub) ?? widget.widePosterScale, + ]; + final metricsByHub = [ + for (var i = 0; i < widget.hubs.length; i++) + TvBrowseRailLayout.metricsForHub( + hub: widget.hubs[i], + availableWidth: availableWidth, + density: density, + episodePosterMode: modes[i], + scale: scale, + tallPosterScale: widget.tallPosterScale, + widePosterScale: wideScales[i], + ), + ]; + final sectionHeights = [ + for (final metrics in metricsByHub) + TvBrowseRailLayout.hubSectionHeightFor(scale: scale, activeRailHeight: metrics.height), + ]; + final offsets = []; + var nextOffset = 0.0; + for (final height in sectionHeights) { + offsets.add(nextOffset); + nextOffset += height; + } + _sectionOffsets = offsets; - 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, + var viewportSectionHeight = 0.0; + for (final height in sectionHeights) { + if (height > viewportSectionHeight) viewportSectionHeight = height; + } + final viewportHeight = TvBrowseRailLayout.viewportHeightFor( + hubCount: widget.hubs.length, + scale: scale, + sectionHeight: viewportSectionHeight, + ); + final bottomPadding = (viewportHeight - sectionHeights.last).clamp(0.0, double.infinity).toDouble(); + _sectionMaxScrollExtent = (nextOffset + bottomPadding - viewportHeight) + .clamp(0.0, double.infinity) + .toDouble(); + final totalHeight = + TvBrowseRailLayout.railTopPaddingForScale(scale) + + viewportHeight + + TvBrowseRailLayout.railBottomPaddingForScale(scale); + + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: 1, + child: SizedBox( + height: totalHeight, + child: Container( + padding: EdgeInsets.fromLTRB( + horizontalInset, + TvBrowseRailLayout.railTopPaddingForScale(scale), + 0, + TvBrowseRailLayout.railBottomPaddingForScale(scale), + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)], ), - 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: 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, - ), - ), - ), - ); - }, + ), + child: AnimatedOpacity( + opacity: hasFocus ? 1 : 0.6, + duration: FocusTheme.getAnimationDuration(context), + curve: Curves.easeOutCubic, + child: ClipRect( + clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0), + child: SizedBox( + height: viewportHeight, + child: _buildHubSectionList( + context, + hasFocus: hasFocus, + modes: modes, + metricsByHub: metricsByHub, + sectionHeights: sectionHeights, + scale: scale, + leftOverflow: horizontalInset, + interactionExpansion: interactionExpansion, + railViewportWidth: railViewportWidth, + bottomPadding: bottomPadding, + ), ), ), ), @@ -983,6 +827,225 @@ class TvBrowseRailState extends State { ); } + Widget _buildHubSectionList( + BuildContext context, { + required bool hasFocus, + required List modes, + required List metricsByHub, + required List sectionHeights, + required double scale, + required double leftOverflow, + required double interactionExpansion, + required double railViewportWidth, + required double bottomPadding, + }) { + return ListView.builder( + key: const ValueKey('tv_browse_rail_vertical'), + controller: _verticalController, + physics: const NeverScrollableScrollPhysics(), + clipBehavior: Clip.none, + padding: EdgeInsets.only(bottom: bottomPadding), + itemExtentBuilder: (index, _) => sectionHeights[index], + itemCount: widget.hubs.length, + itemBuilder: (context, hubIndex) { + final hub = widget.hubs[hubIndex]; + final isActive = hubIndex == _hubIndex; + final metrics = metricsByHub[hubIndex]; + final sectionHeight = sectionHeights[hubIndex]; + + return SizedBox( + key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()), + height: sectionHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale), + SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)), + _buildHubRail( + context, + hub: hub, + hubIndex: hubIndex, + hasFocus: hasFocus, + episodePosterMode: modes[hubIndex], + metrics: metrics, + scale: scale, + leftOverflow: leftOverflow, + interactionExpansion: interactionExpansion, + railViewportWidth: railViewportWidth, + ), + ], + ), + ); + }, + ); + } + + Widget _buildHubHeader( + BuildContext context, { + required MediaHub hub, + required int hubIndex, + required bool isActive, + required double scale, + }) { + final colorScheme = Theme.of(context).colorScheme; + final titleColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.54); + final iconColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.42); + + return SizedBox( + height: TvBrowseRailLayout.hubStripHeightForScale(scale), + child: ExcludeFocus( + child: Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + AppIcon(widget.iconForHub(hub, hubIndex), fill: 1, size: 20 * scale, color: iconColor), + SizedBox(width: 8 * scale), + Expanded( + child: Text( + hub.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: titleColor, + fontSize: 18 * scale, + height: 1, + fontWeight: isActive ? FontWeight.w800 : FontWeight.w700, + ), + ), + ), + if (hub.more) ...[ + SizedBox(width: 8 * scale), + AppIcon(Symbols.chevron_right_rounded, fill: 1, size: 20 * scale, color: iconColor), + SizedBox(width: 30 * scale), + ], + ], + ), + ), + ), + ); + } + + Widget _buildHubRail( + BuildContext context, { + required MediaHub hub, + required int hubIndex, + required bool hasFocus, + required EpisodePosterMode episodePosterMode, + required TvBrowseRailLayoutMetrics metrics, + required double scale, + required double leftOverflow, + required double interactionExpansion, + required double railViewportWidth, + }) { + final isActiveHub = hubIndex == _hubIndex; + final totalCount = _totalItemCount(hub); + final inactiveIndex = HubFocusMemory.getForHubOnly(hub.id, totalCount); + final focusedIndex = isActiveHub ? _itemIndex : inactiveIndex; + final scrollController = _scrollControllerForHub(hub, metrics, railViewportWidth, scale, focusedIndex); + if (isActiveHub) { + _railLeadingPadding = metrics.railEdgePadding; + _itemExtent = metrics.cardWidth + metrics.itemGap; + } + + return Transform.translate( + offset: Offset(-interactionExpansion, 0), + child: SizedBox( + width: railViewportWidth, + height: metrics.height, + child: ClipRect( + clipper: _RailClipper( + leftOverflow: leftOverflow, + 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.fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), + itemCount: totalCount, + itemBuilder: (context, itemIndex) { + final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex; + if (itemIndex == hub.items.length) { + return Padding( + padding: EdgeInsets.only(right: metrics.itemGap), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () { + _selectHubItem(hub, hubIndex, itemIndex); + _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[itemIndex]; + return Padding( + padding: EdgeInsets.only(right: metrics.itemGap), + child: MouseRegion( + onEnter: (_) => _setHoveredItem(hub, itemIndex), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () { + _selectHubItem(hub, hubIndex, itemIndex); + unawaited(_activateCurrentItem()); + }, + onLongPress: metrics.isPersonHub + ? null + : () { + _selectHubItem(hub, hubIndex, itemIndex); + _cardKeyFor(hub, itemIndex).currentState?.showContextMenu(); + }, + child: metrics.isPersonHub + ? _buildPersonCard( + context, + item, + cardWidth: metrics.cardWidth, + imageSize: metrics.posterHeight, + scale: scale, + ) + : MediaCard( + key: _cardKeyFor(hub, itemIndex), + 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, + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } + Widget _buildPersonCard( BuildContext context, MediaItem item, { @@ -1046,17 +1109,20 @@ class TvBrowseRailState extends State { } class _RailClipper extends CustomClipper { + final double leftOverflow; final double rightOverflow; final double verticalOverflow; - const _RailClipper({required this.rightOverflow, required this.verticalOverflow}); + const _RailClipper({this.leftOverflow = 0, required this.rightOverflow, required this.verticalOverflow}); @override Rect getClip(Size size) => - Rect.fromLTRB(0, -verticalOverflow, size.width + rightOverflow, size.height + verticalOverflow); + Rect.fromLTRB(-leftOverflow, -verticalOverflow, size.width + rightOverflow, size.height + verticalOverflow); @override bool shouldReclip(covariant _RailClipper oldClipper) { - return oldClipper.rightOverflow != rightOverflow || oldClipper.verticalOverflow != verticalOverflow; + return oldClipper.leftOverflow != leftOverflow || + oldClipper.rightOverflow != rightOverflow || + oldClipper.verticalOverflow != verticalOverflow; } } diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 6e97d20f..cf76870c 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -166,8 +166,8 @@ class TvSpotlightBackground extends StatelessWidget { if (summary != null && summary.isNotEmpty) ...[ SizedBox(height: _sectionGap(scale)), Text( - _summaryText(media, summary), - maxLines: compact ? 2 : 4, + summary, + maxLines: compact ? 3 : 4, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Colors.white.withValues(alpha: 0.78), @@ -178,7 +178,7 @@ class TvSpotlightBackground extends StatelessWidget { ] else if (shouldHideSpoiler && media.isEpisode) ...[ SizedBox(height: _sectionGap(scale)), Text( - _episodePrefix(media) ?? media.title ?? '', + media.title ?? '', maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith( @@ -250,12 +250,17 @@ class TvSpotlightBackground extends StatelessWidget { Widget _buildMetadataLine(BuildContext context, MediaItem media) { final scale = _scale(context); + final episodeLabel = formatSeasonEpisodeLabel(media.parentIndex, media.index); final parts = [ + if (media.isEpisode && episodeLabel != null) episodeLabel, if (media.isMovie) t.discover.movie else if (media.isShow) t.discover.tvShow, if (media.rating != null) '★ ${formatRating(media.rating!)}', if (media.contentRating != null) formatContentRating(media.contentRating!), if (media.durationMs != null) formatDurationTextual(media.durationMs!), - if (media.year != null) media.year.toString(), + if (media.isEpisode && media.originallyAvailableAt != null) + formatFullDate(media.originallyAvailableAt!) + else if (media.year != null) + media.year.toString(), ]; return Text( parts.join(' • '), @@ -310,15 +315,4 @@ class TvSpotlightBackground extends StatelessWidget { ), ); } - - String _summaryText(MediaItem media, String summary) { - final prefix = _episodePrefix(media); - if (prefix == null) return summary; - return '$prefix: $summary'; - } - - String? _episodePrefix(MediaItem media) { - if (!media.isEpisode || media.parentIndex == null || media.index == null) return null; - return 'S${media.parentIndex}, E${media.index}'; - } } diff --git a/test/utils/formatters_test.dart b/test/utils/formatters_test.dart index 155fb8af..b3aa19f2 100644 --- a/test/utils/formatters_test.dart +++ b/test/utils/formatters_test.dart @@ -114,6 +114,18 @@ void main() { }); }); + group('formatSeasonEpisodeLabel', () { + test('formats season and episode numbers', () { + expect(formatSeasonEpisodeLabel(1, 2), 'S1 E2'); + expect(formatSeasonEpisodeLabel(0, 10), 'S0 E10'); + }); + + test('requires both season and episode numbers', () { + expect(formatSeasonEpisodeLabel(null, 2), isNull); + expect(formatSeasonEpisodeLabel(1, null), isNull); + }); + }); + group('formatPlaybackRate', () { test('1x formats as "1x" without normalAtOne', () { expect(formatPlaybackRate(1.0), '1x'); diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index b20b1fac..5a961100 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/dpad_navigator.dart'; import 'package:plezy/focus/locked_hub_controller.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_hub.dart'; @@ -68,10 +69,21 @@ void main() { episodePosterMode: EpisodePosterMode.episodeThumbnail, scale: 0.85, ); + final compactForcedLayout = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: 1040, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: 0.85, + widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale, + ); expect(defaultLayout.useWideLayout, isFalse); expect(forcedLayout.useWideLayout, isTrue); expect(forcedLayout.posterHeight, lessThan(defaultLayout.posterHeight)); + expect(compactForcedLayout.useWideLayout, isTrue); + expect(compactForcedLayout.cardWidth, lessThan(forcedLayout.cardWidth)); + expect(compactForcedLayout.posterHeight, lessThan(forcedLayout.posterHeight)); }); test('estimated rail height is stable across mixed hub heights', () { @@ -112,15 +124,26 @@ void main() { scale: scale, ); + final estimate = TvBrowseRailLayout.estimateHeight( + size: size, + hubs: [wideHub, posterHub], + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + ); + final posterSectionHeight = TvBrowseRailLayout.hubSectionHeightFor( + scale: scale, + activeRailHeight: posterMetrics.height, + ); + final expectedPosterEstimate = + TvBrowseRailLayout.railTopPaddingForScale(scale) + + TvBrowseRailLayout.viewportHeightFor(hubCount: 2, scale: scale, sectionHeight: posterSectionHeight) + + TvBrowseRailLayout.railBottomPaddingForScale(scale); + expect(posterMetrics.height, greaterThan(wideMetrics.height)); expect(maxHeight, posterMetrics.height); + expect(estimate, closeTo(expectedPosterEstimate, 0.001)); expect( - TvBrowseRailLayout.estimateHeight( - size: size, - hubs: [wideHub, posterHub], - density: LibraryDensity.max, - episodePosterMode: EpisodePosterMode.episodeThumbnail, - ), + estimate, TvBrowseRailLayout.estimateHeight( size: size, hubs: [posterHub, wideHub], @@ -151,6 +174,29 @@ void main() { expect(compactHeight, lessThan(defaultHeight)); }); + + test('multi-hub estimate reserves next hub peek height', () { + final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); + final showHub = MediaHub(id: 'shows', title: 'Shows', type: 'show', items: [movie], size: 1); + + const size = Size(1280, 720); + final scale = TvBrowseRailLayout.scaleForSize(size); + final singleHubHeight = TvBrowseRailLayout.estimateHeight( + size: size, + hubs: [movieHub], + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.seriesPoster, + ); + final multiHubHeight = TvBrowseRailLayout.estimateHeight( + size: size, + hubs: [movieHub, showHub], + density: LibraryDensity.max, + episodePosterMode: EpisodePosterMode.seriesPoster, + ); + + expect(multiHubHeight - singleHubHeight, closeTo(TvBrowseRailLayout.nextHubPeekHeightForScale(scale), 0.001)); + }); }); setUp(() async { @@ -319,6 +365,8 @@ void main() { 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(); + final activeHubIds = []; + var parentRebuilds = 0; HubFocusMemory.setForHub(episodeHub.id, 5); await tester.pumpWidget( @@ -327,15 +375,23 @@ void main() { 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, - ), + body: StatefulBuilder( + builder: (context, setParentState) { + return SizedBox( + width: 700, + height: 720, + child: TvBrowseRail( + hubs: [movieHub, episodeHub], + autofocus: true, + iconForHub: (_, _) => Icons.tv_rounded, + onActiveHubChanged: (hub, _) { + activeHubIds.add(hub.id); + setParentState(() => parentRebuilds++); + }, + episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail, + ), + ); + }, ), ), ), @@ -345,12 +401,34 @@ void main() { tester.state(find.byType(TvBrowseRail)).requestFocus(); await tester.pump(); + final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); + final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale); + final movieMetrics = TvBrowseRailLayout.metricsForHub( + hub: movieHub, + availableWidth: availableWidth, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: scale, + ); + final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor( + scale: scale, + activeRailHeight: movieMetrics.height, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 100)); + + final midAnimationPosition = _verticalRailPosition(tester).pixels; + expect(midAnimationPosition, greaterThan(0)); + expect(midAnimationPosition, lessThan(expectedVerticalOffset)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); final position = _activeRailPosition(tester); - final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); + final verticalPosition = _verticalRailPosition(tester); final metrics = TvBrowseRailLayout.metricsForHub( hub: episodeHub, availableWidth: position.viewportDimension, @@ -362,7 +440,148 @@ void main() { final targetCenter = metrics.railEdgePadding + (5 * itemExtent) + (itemExtent / 2); final expectedOffset = (targetCenter - (position.viewportDimension / 2)).clamp(0.0, position.maxScrollExtent); + expect(activeHubIds.last, episodeHub.id); + expect(parentRebuilds, greaterThan(0)); expect(position.pixels, closeTo(expectedOffset, 0.1)); + expect(verticalPosition.pixels, closeTo(expectedVerticalOffset, 0.1)); + }); + + testWidgets('uses per-hub item focus instead of global column hint', (tester) async { + List movieItems() => List.generate( + 8, + (index) => + MediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'), + ); + List episodeItems() => List.generate( + 8, + (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: 8); + final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 8); + final focused = []; + final serverManager = MultiServerManager(); + + Future press(LogicalKeyboardKey key) async { + await tester.sendKeyDownEvent(key); + await tester.pump(); + await tester.sendKeyUpEvent(key); + await tester.pump(); + } + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setParentState) { + return SizedBox( + width: 700, + height: 720, + child: TvBrowseRail( + hubs: [movieHub, episodeHub], + autofocus: true, + iconForHub: (_, _) => Icons.tv_rounded, + onActiveHubChanged: (_, _) => setParentState(() {}), + onFocusedHubItemChanged: (hub, item) => focused.add('${hub.id}:${item.id}'), + episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail, + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pump(); + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + + for (var i = 0; i < 5; i++) { + await press(LogicalKeyboardKey.arrowRight); + } + expect(focused.last, 'movies:movie_5'); + + await press(LogicalKeyboardKey.arrowDown); + expect(focused.last, 'episodes:episode_0'); + + await press(LogicalKeyboardKey.arrowUp); + expect(focused.last, 'movies:movie_5'); + }); + + testWidgets('resets long-press state when context menu focus receives select key up', (tester) async { + final menuFocusNode = FocusNode(debugLabel: 'context_menu_probe'); + addTearDown(menuFocusNode.dispose); + addTearDown(SelectKeyUpSuppressor.clearSuppression); + + var activations = 0; + final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); + final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1); + final serverManager = MultiServerManager(); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: Stack( + children: [ + SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + hubs: [hub], + iconForHub: (_, _) => Icons.person_rounded, + onActivateItem: (_, _) { + activations++; + return Future.value(true); + }, + ), + ), + Focus( + focusNode: menuFocusNode, + onKeyEvent: (_, event) { + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled; + return KeyEventResult.ignored; + }, + child: const SizedBox.shrink(), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + final railState = tester.state(find.byType(TvBrowseRail)); + railState.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 501)); + + menuFocusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + railState.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 100)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(activations, 1); }); testWidgets('does not autofocus unless requested', (tester) async { @@ -395,12 +614,58 @@ void main() { await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); }); + + testWidgets('lays out when bottom-positioned in a stack', (tester) async { + final serverManager = MultiServerManager(); + final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 896, + height: 540, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); } ScrollPosition _activeRailPosition(WidgetTester tester) { return tester .stateList(find.byType(Scrollable)) .map((state) => state.position) + .where((position) => axisDirectionToAxis(position.axisDirection) == Axis.horizontal) .where((position) => position.maxScrollExtent > 0) .reduce((a, b) => a.maxScrollExtent > b.maxScrollExtent ? a : b); } + +ScrollPosition _verticalRailPosition(WidgetTester tester) { + final scrollable = find.descendant( + of: find.byKey(const ValueKey('tv_browse_rail_vertical')), + matching: find.byType(Scrollable), + ); + return tester + .stateList(scrollable) + .map((state) => state.position) + .singleWhere((position) => axisDirectionToAxis(position.axisDirection) == Axis.vertical); +}