From c38f24aee971ca37afd8717f7b43b5b49f669e54 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:29:24 +0200 Subject: [PATCH] perf(tv): isolate rail rebuilds; dim rows via scrims, not opacity Hub moves, focus flips and item selects now flow through the rail focus model + ListenableSelectors (two headers/cards repaint instead of every visible card rebuilding). DiscoverScreen memoizes the hubs list, gates provider notifies on a render signature and caches the rail widget so no-op refreshes skip the subtree entirely. The whole-rail 0.6 and inactive-row 0.7 AnimatedOpacity layers are replaced by AnimatedDimScrim quads, removing the permanent per-frame saveLayers that kept low-end TV GPUs raster-bound. --- lib/screens/discover_screen.dart | 90 ++++-- lib/widgets/animated_dim_scrim.dart | 40 +++ lib/widgets/tv_browse_rail.dart | 433 +++++++++++++++++----------- 3 files changed, 362 insertions(+), 201 deletions(-) create mode 100644 lib/widgets/animated_dim_scrim.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 6d3fbcf0..079e5a60 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -186,7 +186,16 @@ class _DiscoverScreenState extends State return null; } + // Memoized on provider list identity (the provider always replaces _onDeck/ + // _hubs with fresh instances on change, never mutates in place) so unrelated + // rebuilds hand TvBrowseRail the same hubs list and its didUpdateWidget + // fast path — and the cached rail widget below — kick in. + List? _tvBrowseHubsCache; + (List, List, bool, String)? _tvBrowseHubsCacheKey; + List get _tvBrowseHubs { + final key = (_onDeck, _hubs, _hasMoreContinueWatching, t.discover.continueWatching); + if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!; final hubs = []; if (_onDeck.isNotEmpty) { hubs.add( @@ -202,6 +211,8 @@ class _DiscoverScreenState extends State ); } hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty)); + _tvBrowseHubsCache = hubs; + _tvBrowseHubsCacheKey = key; return hubs; } @@ -379,19 +390,34 @@ class _DiscoverScreenState extends State /// pending TV-rail focus, and keep the hero carousel index in sync — a /// fresh [DiscoverProvider.load] resets it, a background Continue Watching /// refresh only clamps it. + /// Everything the build reads from the provider (list identities — the + /// provider replaces lists on change — plus the scalar flags). Notifies + /// that leave this unchanged (e.g. a watch-state-driven Continue Watching + /// refresh that found nothing new) skip the setState so the whole screen — + /// TV rail included — is not rebuilt for nothing. + (List, List, bool, bool, bool, String?) get _renderSignature => + (_onDeck, _hubs, _hasMoreContinueWatching, _isLoading, _areHubsLoading, _discover.errorMessage); + + (List, List, bool, bool, bool, String?)? _seenRenderSignature; + void _onDiscoverChanged() { if (!mounted) return; final generation = _discover.loadGeneration; final isNewLoad = generation != _seenLoadGeneration; _seenLoadGeneration = generation; final heroOutOfBounds = _currentHeroIndex >= _onDeck.length; + final signature = _renderSignature; + final renderChanged = isNewLoad || heroOutOfBounds || signature != _seenRenderSignature; + _seenRenderSignature = signature; - setState(() { - if (isNewLoad || heroOutOfBounds) { - _currentHeroIndex = 0; - } - _updateHubKeys(); - }); + if (renderChanged) { + setState(() { + if (isNewLoad || heroOutOfBounds) { + _currentHeroIndex = 0; + } + _updateHubKeys(); + }); + } _applyPendingTvBrowseRailFocus(); if ((isNewLoad || heroOutOfBounds) && _heroController.hasClients && _onDeck.isNotEmpty) { @@ -1205,6 +1231,37 @@ class _DiscoverScreenState extends State ); } + // Cached so unrelated _buildTvContent rebuilds (loading flags, spotlight + // geometry) hand Element.updateChild the identical widget instance and the + // whole rail subtree is skipped. Rebuilt only when its actual inputs change. + TvBrowseRail? _tvBrowseRailWidget; + (List, bool)? _tvBrowseRailWidgetKey; + + Widget _cachedTvBrowseRail(List browseHubs, {required bool showServerName}) { + final key = (browseHubs, showServerName); + if (_tvBrowseRailWidget != null && key == _tvBrowseRailWidgetKey) return _tvBrowseRailWidget!; + _tvBrowseRailWidgetKey = key; + return _tvBrowseRailWidget = TvBrowseRail( + key: _tvBrowseRailKey, + hubs: browseHubs, + showServerName: showServerName, + iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), + onFocusedItemChanged: _setSpotlightItem, + onRefresh: _discover.updateItem, + onRemoveFromContinueWatching: _discover.refreshContinueWatching, + isContinueWatchingHub: (hub) => hub.isContinueWatchingHub, + usesContinueWatchingAction: (hub) => hub.usesContinueWatchingAction, + loadMoreItems: (hub) => + hub.id == 'continue_watching' ? _discover.loadAllContinueWatching() : Future.value(hub.items), + onNavigateUp: _focusTopActions, + onNavigateToSidebar: _navigateToSidebar, + tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, + selectSuppressionGestureSignal: PlatformDetector.isAppleTV() + ? AppleTvRemoteTouchService.instance.touchActiveListenable + : null, + ); + } + Widget _buildTvContent(BuildContext context) { final size = MediaQuery.sizeOf(context); final theme = Theme.of(context); @@ -1307,26 +1364,7 @@ class _DiscoverScreenState extends State left: 0, right: 0, bottom: 0, - child: TvBrowseRail( - key: _tvBrowseRailKey, - hubs: browseHubs, - showServerName: showServerNameOnHubs || hubsSpanMultipleServers, - iconForHub: (hub, _) => - hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), - onFocusedItemChanged: _setSpotlightItem, - onRefresh: _discover.updateItem, - onRemoveFromContinueWatching: _discover.refreshContinueWatching, - isContinueWatchingHub: (hub) => hub.isContinueWatchingHub, - usesContinueWatchingAction: (hub) => hub.usesContinueWatchingAction, - loadMoreItems: (hub) => - hub.id == 'continue_watching' ? _discover.loadAllContinueWatching() : Future.value(hub.items), - onNavigateUp: _focusTopActions, - onNavigateToSidebar: _navigateToSidebar, - tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, - selectSuppressionGestureSignal: PlatformDetector.isAppleTV() - ? AppleTvRemoteTouchService.instance.touchActiveListenable - : null, - ), + child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers), ), Builder( builder: (context) => SideNavigationBleedBuilder( diff --git a/lib/widgets/animated_dim_scrim.dart b/lib/widgets/animated_dim_scrim.dart new file mode 100644 index 00000000..5ffc8c6a --- /dev/null +++ b/lib/widgets/animated_dim_scrim.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; + +import '../focus/focus_theme.dart'; + +/// Dims the content beneath it by drawing a translucent [color] quad on top, +/// as a saveLayer-free replacement for wrapping the content in +/// [AnimatedOpacity]. Widget opacity below 1.0 forces an offscreen render +/// pass every frame — a permanent GPU cost on the weak GLES devices most +/// Android TVs are — while a color-blended quad is free. Over a +/// [color]-colored underlay the result is mathematically identical to +/// `Opacity(opacity: 1 - alpha)`; over artwork it darkens toward [color] +/// instead of turning translucent. +/// +/// Stack this above the content (with `IgnorePointer` built in so taps pass +/// through). Animates with [FocusTheme.getAnimationDuration], so the full +/// tier fades and the reduced tier snaps, matching the [AnimatedOpacity] +/// behavior it replaces. +class AnimatedDimScrim extends StatelessWidget { + const AnimatedDimScrim({super.key, required this.dimmed, required this.color, required this.alpha}); + + final bool dimmed; + final Color color; + + /// Scrim strength while [dimmed]; visually equivalent to + /// `Opacity(opacity: 1 - alpha)` over a [color] underlay. + final double alpha; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: TweenAnimationBuilder( + tween: Tween(end: dimmed ? alpha : 0.0), + duration: FocusTheme.getAnimationDuration(context), + curve: Curves.easeOutCubic, + builder: (context, value, _) => + value == 0 ? const SizedBox.shrink() : ColoredBox(color: color.withValues(alpha: value)), + ), + ); + } +} diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 6e40f886..85b8c76d 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -23,6 +23,7 @@ import '../utils/media_image_helper.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/provider_extensions.dart'; import '../utils/layout_constants.dart'; +import 'animated_dim_scrim.dart'; import 'app_icon.dart'; import 'clickable_cursor.dart'; import 'focus_builders.dart'; @@ -388,7 +389,10 @@ class TvBrowseRailState extends State { static const _navigationScrollDuration = Duration(milliseconds: 130); static const _repeatNavigationScrollDuration = Duration(milliseconds: 65); static const _scrollCatchUpViewportDistance = 2.5; - static const _inactiveHubContentOpacity = 0.7; + // Dim strengths as scrim alphas (see AnimatedDimScrim): equivalent to the + // former whole-rail Opacity(0.6) and inactive-row Opacity(0.7) layers. + static const _unfocusedRailDimAlpha = 0.4; + static const _inactiveHubDimAlpha = 0.3; final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); final Map _scrollControllers = {}; @@ -402,10 +406,11 @@ class TvBrowseRailState extends State { int _hubIndex = 0; int _itemIndex = 0; - /// Mirrors (_hubIndex, _itemIndex) for the per-card focus selectors, so - /// plain left/right moves repaint only the two affected cards instead of - /// setState-rebuilding every visible row (expensive on low-end TVs). - final _RailFocusPosition _focusPosition = _RailFocusPosition(); + /// Mirrors (_hubIndex, _itemIndex) plus the rail's focus state for the + /// per-card/header/dim selectors, so d-pad moves and focus flips repaint + /// only the affected subtrees instead of setState-rebuilding every visible + /// row (expensive on low-end TVs). + final _RailFocusModel _focusModel = _RailFocusModel(); List _sectionOffsets = const []; double _sectionMaxScrollExtent = 0; Timer? _longPressTimer; @@ -460,7 +465,7 @@ class TvBrowseRailState extends State { _focusNode.addListener(_handleFocusChange); _selectInitialHubIfPossible(); final selectedInitialItem = _selectInitialItemIfPossible(); - _focusPosition.set(_hubIndex, _itemIndex, notify: false); + _focusModel.set(_hubIndex, _itemIndex, notify: false); _rememberTrailingStates(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || widget.hubs.isEmpty) return; @@ -497,7 +502,7 @@ class TvBrowseRailState extends State { if (widget.hubs.isEmpty) { _hubIndex = 0; _itemIndex = 0; - _focusPosition.set(_hubIndex, _itemIndex, notify: false); + _focusModel.set(_hubIndex, _itemIndex, notify: false); _rememberTrailingStates(); return; } @@ -520,7 +525,7 @@ class TvBrowseRailState extends State { final selectedInitialItem = _selectInitialItemIfPossible(); // notify:false — this runs during the build phase and the enclosing // rebuild already refreshes every selector. - _focusPosition.set(_hubIndex, _itemIndex, notify: false); + _focusModel.set(_hubIndex, _itemIndex, notify: false); final newActiveHub = _activeHub; final activeHubChanged = oldActiveHubKey != (newActiveHub == null ? null : _hubKey(newActiveHub)); final activeHubStateChanged = @@ -592,7 +597,7 @@ class TvBrowseRailState extends State { _detachGestureSignalListener(); _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); - _focusPosition.dispose(); + _focusModel.dispose(); for (final controller in _scrollControllers.values) { controller.dispose(); } @@ -603,7 +608,10 @@ class TvBrowseRailState extends State { void _handleFocusChange() { if (!_focusNode.hasFocus) _resetLongPressState(); if (_focusNode.hasFocus) _notifyFocusedItem(); - setState(() {}); + // No setState: rail focus is observed through _focusModel selectors + // (per-card focus wrappers, headers and the dim layer), so a focus flip + // repaints only those subtrees instead of rebuilding every visible row. + _focusModel.setRailFocus(_focusNode.hasFocus); } void _resetLongPressState() { @@ -721,10 +729,10 @@ class TvBrowseRailState extends State { if (key.isLeftKey) { if (_itemIndex > 0) { // No setState: the per-card focus selectors repaint the two affected - // cards via _focusPosition; nothing else in the rail depends on it. + // cards via _focusModel; nothing else in the rail depends on it. _itemIndex--; _hasUserChangedItem = true; - _focusPosition.set(_hubIndex, _itemIndex); + _focusModel.set(_hubIndex, _itemIndex); _rememberFocus(hub); _notifyFocusedItem(); _scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration); @@ -738,7 +746,7 @@ class TvBrowseRailState extends State { if (_itemIndex < _totalItemCount(hub) - 1) { _itemIndex++; _hasUserChangedItem = true; - _focusPosition.set(_hubIndex, _itemIndex); + _focusModel.set(_hubIndex, _itemIndex); _rememberFocus(hub); _notifyFocusedItem(); _scrollToItem(duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration); @@ -776,12 +784,14 @@ class TvBrowseRailState extends State { if (currentHub != null) _rememberFocus(currentHub); final nextHub = widget.hubs[next]; final remembered = HubFocusMemory.getForHubOnly(_hubKey(nextHub), _totalItemCount(nextHub)); - setState(() { - _hubIndex = next; - _itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1); - _hasUserChangedHub = true; - _focusPosition.set(_hubIndex, _itemIndex, notify: false); - }); + // No setState: the active-hub change is observed through _focusModel + // selectors (cards, headers, row dim), so a hub move repaints only the + // two affected rows instead of rebuilding every visible card. Section + // extents don't depend on the active hub, so no relayout is needed. + _hubIndex = next; + _itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1); + _hasUserChangedHub = true; + _focusModel.set(_hubIndex, _itemIndex); _notifyFocusedItem(); _notifyActiveHubChanged(); _scrollToItemAfterLayout(animate: false); @@ -829,7 +839,7 @@ class TvBrowseRailState extends State { } _itemIndex = index; _hasUserChangedItem = true; - _focusPosition.set(_hubIndex, _itemIndex); + _focusModel.set(_hubIndex, _itemIndex); _rememberFocus(hub); _notifyFocusedItem(); } @@ -842,13 +852,12 @@ class TvBrowseRailState extends State { final hubChanged = _hubIndex != hubIndex; final previousHub = _activeHub; if (hubChanged && previousHub != null) _rememberFocus(previousHub); - setState(() { - _hubIndex = hubIndex; - _itemIndex = clampedItemIndex; - _hasUserChangedHub = true; - _hasUserChangedItem = true; - _focusPosition.set(_hubIndex, _itemIndex, notify: false); - }); + // No setState: observed through _focusModel selectors (see _moveHub). + _hubIndex = hubIndex; + _itemIndex = clampedItemIndex; + _hasUserChangedHub = true; + _hasUserChangedItem = true; + _focusModel.set(_hubIndex, _itemIndex); _rememberFocus(hub); _notifyFocusedItem(); if (hubChanged) _notifyActiveHubChanged(); @@ -1010,7 +1019,6 @@ class TvBrowseRailState extends State { builder: (context) => LayoutBuilder( builder: (context, constraints) { final svc = SettingsService.instance; - final hasFocus = _focusNode.hasFocus; final theme = Theme.of(context); final scale = _scale(context); final horizontalInset = _horizontalInset(context); @@ -1092,28 +1100,44 @@ class TvBrowseRailState extends State { 0, TvBrowseRailLayout.railBottomPaddingForScale(scale), ), - child: AnimatedOpacity( - opacity: hasFocus ? 1 : 0.6, - duration: FocusTheme.getAnimationDuration(context), - curve: Curves.easeOutCubic, - child: ClipRect( - clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0), - child: SizedBox( - height: viewportHeight, - child: _buildHubSectionList( - hasFocus: hasFocus, - modes: modes, - metricsByHub: metricsByHub, - sectionHeights: sectionHeights, - scale: scale, - fullCardLayout: fullCardLayout, - leftOverflow: horizontalInset, - interactionExpansion: interactionExpansion, - railViewportWidth: railViewportWidth, - bottomPadding: bottomPadding, + // Unfocused-rail dim: a scrim quad on top instead of + // AnimatedOpacity, which would keep a full-viewport + // saveLayer alive every frame (see AnimatedDimScrim). + // The scrim extends into the clipper's left overflow so + // partially scrolled-out cards dim too. + child: Stack( + clipBehavior: Clip.none, + children: [ + ClipRect( + clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0), + child: SizedBox( + height: viewportHeight, + child: _buildHubSectionList( + modes: modes, + metricsByHub: metricsByHub, + sectionHeights: sectionHeights, + scale: scale, + fullCardLayout: fullCardLayout, + leftOverflow: horizontalInset, + interactionExpansion: interactionExpansion, + railViewportWidth: railViewportWidth, + bottomPadding: bottomPadding, + ), ), ), - ), + Positioned.fill( + left: -horizontalInset, + child: ListenableSelector( + listenable: _focusModel, + selector: () => _focusModel.railHasFocus, + builder: (context, railHasFocus, _) => AnimatedDimScrim( + dimmed: !railHasFocus, + color: theme.scaffoldBackgroundColor, + alpha: _unfocusedRailDimAlpha, + ), + ), + ), + ], ), ), ], @@ -1127,7 +1151,6 @@ class TvBrowseRailState extends State { } Widget _buildHubSectionList({ - required bool hasFocus, required List modes, required List metricsByHub, required List sectionHeights, @@ -1148,9 +1171,12 @@ class TvBrowseRailState extends State { itemCount: widget.hubs.length, itemBuilder: (context, hubIndex) { final hub = widget.hubs[hubIndex]; - final isActive = hubIndex == _hubIndex; final metrics = metricsByHub[hubIndex]; final sectionHeight = sectionHeights[hubIndex]; + // Active-hub state is observed through _focusModel so a hub move + // repaints only the two affected headers/dim layers; the row content + // below is passed through as a stable child. + bool isActiveHub() => _focusModel.hubIndex == hubIndex; return SizedBox( key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()), @@ -1158,24 +1184,23 @@ class TvBrowseRailState extends State { child: Column( crossAxisAlignment: .stretch, children: [ - _buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale), + ListenableSelector( + listenable: _focusModel, + selector: isActiveHub, + builder: (context, isActive, _) => + _buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale), + ), SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)), - AnimatedOpacity( - opacity: isActive ? 1 : _inactiveHubContentOpacity, - duration: FocusTheme.getAnimationDuration(context), - curve: Curves.easeOutCubic, - child: _buildHubRail( - hub: hub, - hubIndex: hubIndex, - hasFocus: hasFocus, - episodePosterMode: modes[hubIndex], - metrics: metrics, - scale: scale, - fullCardLayout: fullCardLayout, - leftOverflow: leftOverflow, - interactionExpansion: interactionExpansion, - railViewportWidth: railViewportWidth, - ), + _buildHubRail( + hub: hub, + hubIndex: hubIndex, + episodePosterMode: modes[hubIndex], + metrics: metrics, + scale: scale, + fullCardLayout: fullCardLayout, + leftOverflow: leftOverflow, + interactionExpansion: interactionExpansion, + railViewportWidth: railViewportWidth, ), ], ), @@ -1251,7 +1276,6 @@ class TvBrowseRailState extends State { Widget _buildHubRail({ required MediaHub hub, required int hubIndex, - required bool hasFocus, required EpisodePosterMode episodePosterMode, required TvBrowseRailLayoutMetrics metrics, required double scale, @@ -1268,6 +1292,7 @@ class TvBrowseRailState extends State { _metricsByHub[_hubKey(hub)] = metrics; _scaleByHub[_hubKey(hub)] = scale; + final rightOverflow = metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap; return Transform.translate( offset: Offset(-interactionExpansion, 0), child: SizedBox( @@ -1276,116 +1301,163 @@ class TvBrowseRailState extends State { child: ClipRect( clipper: _RailClipper( leftOverflow: leftOverflow, - rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap, + rightOverflow: rightOverflow, verticalOverflow: metrics.focusExtra, ), - child: HorizontalScrollWithArrows( - controller: scrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: .fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), - itemExtentBuilder: (itemIndex, _) => TvBrowseRailLayout.itemExtentForIndex( + // Inactive-row dim: a scrim quad on top of the row instead of + // AnimatedOpacity, which would keep one saveLayer per visible row + // alive every frame (see AnimatedDimScrim). Sized to the clip + // region so overflow-painted card slivers dim too. + child: Stack( + clipBehavior: Clip.none, + children: [ + _buildHubRailList( hub: hub, - index: itemIndex, + hubIndex: hubIndex, + episodePosterMode: episodePosterMode, metrics: metrics, scale: scale, - hasTrailing: _hasTrailingFor(hub), + fullCardLayout: fullCardLayout, + scrollController: scrollController, + totalCount: totalCount, ), - itemCount: totalCount, - itemBuilder: (context, itemIndex) { - // Focus is observed through _focusPosition so a d-pad move - // rebuilds only the cheap wrapper of the two affected cards; - // the card content below is passed through as a stable child. - bool isItemFocused() => hasFocus && _focusPosition.value == (hubIndex, itemIndex); - - if (itemIndex == hub.items.length) { - return Padding( - padding: .only(right: metrics.itemGap), - child: Align( - alignment: .centerLeft, - child: ListenableSelector( - listenable: _focusPosition, - selector: isItemFocused, - builder: (context, isFocused, _) => - _buildTrailingSlot(context, hub, hubIndex, itemIndex, isFocused: isFocused, scale: scale), - ), - ), - ); - } - - final item = hub.items[itemIndex]; - final focusableCard = ListenableSelector( - listenable: _focusPosition, - selector: isItemFocused, - builder: (context, isFocused, child) => FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - borderRadius: tokens(context).radiusSm, - focusScale: fullCardLayout ? TvBrowseRailLayout.fullCardFocusScale : FocusTheme.focusScale, - useFocusGlow: fullCardLayout, - // The card draws the border itself (poster rect for - // standard cards, whole card when full-bleed). - delegateFocusBorder: true, - glowSize: fullCardLayout ? Size(metrics.cardWidth, metrics.posterHeight) : null, - onTap: () { - _selectHubItem(hub, hubIndex, itemIndex); - unawaited(_activateCurrentItem()); - }, - onLongPress: metrics.isPersonHub - ? null - : () { - _selectHubItem(hub, hubIndex, itemIndex); - _cardKeyFor(hub, itemIndex).currentState?.showContextMenu(); - }, - child: child!, + Positioned.fill( + left: -leftOverflow, + right: -rightOverflow, + top: -metrics.focusExtra, + bottom: -metrics.focusExtra, + child: ListenableSelector( + listenable: _focusModel, + selector: () => _focusModel.hubIndex == hubIndex, + builder: (context, isActive, _) => AnimatedDimScrim( + dimmed: !isActive, + color: Theme.of(context).scaffoldBackgroundColor, + alpha: _inactiveHubDimAlpha, ), - // MergeSemantics: one node per card (MediaCard merges - // internally) — the per-frame semantics pass scales with - // node count on TV boxes with an accessibility service. - child: metrics.isPersonHub - ? MergeSemantics( - child: _buildPersonCard( - context, - item, - cardWidth: metrics.cardWidth, - imageSize: metrics.posterHeight, - scale: scale, - fullCardLayout: fullCardLayout, - ), - ) - : MediaCard( - key: _cardKeyFor(hub, itemIndex), - item: item, - width: metrics.cardWidth, - height: metrics.posterHeight, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - forceGridMode: true, - fullBleedImage: fullCardLayout, - isInContinueWatching: _isContinueWatchingHub(hub), - usesContinueWatchingAction: _usesContinueWatchingAction(hub), - mixedHubContext: metrics.isMixedHub, - episodePosterModeOverride: episodePosterMode, - ), - ); - - return Padding( - padding: .only(right: metrics.itemGap), - child: MouseRegion( - onEnter: (_) => _setHoveredItem(hub, itemIndex), - child: Align(alignment: .topLeft, child: focusableCard), - ), - ); - }, - ), + ), + ), + ], ), ), ), ); } + Widget _buildHubRailList({ + required MediaHub hub, + required int hubIndex, + required EpisodePosterMode episodePosterMode, + required TvBrowseRailLayoutMetrics metrics, + required double scale, + required bool fullCardLayout, + required ScrollController scrollController, + required int totalCount, + }) { + return HorizontalScrollWithArrows( + controller: scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: .fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), + itemExtentBuilder: (itemIndex, _) => TvBrowseRailLayout.itemExtentForIndex( + hub: hub, + index: itemIndex, + metrics: metrics, + scale: scale, + hasTrailing: _hasTrailingFor(hub), + ), + itemCount: totalCount, + itemBuilder: (context, itemIndex) { + // Focus is observed through _focusModel so a d-pad move or a + // rail focus flip rebuilds only the cheap wrapper of the two + // affected cards; the card content below is passed through as + // a stable child. + bool isItemFocused() => _focusModel.railHasFocus && _focusModel.position == (hubIndex, itemIndex); + + if (itemIndex == hub.items.length) { + return Padding( + padding: .only(right: metrics.itemGap), + child: Align( + alignment: .centerLeft, + child: ListenableSelector( + listenable: _focusModel, + selector: isItemFocused, + builder: (context, isFocused, _) => + _buildTrailingSlot(context, hub, hubIndex, itemIndex, isFocused: isFocused, scale: scale), + ), + ), + ); + } + + final item = hub.items[itemIndex]; + final focusableCard = ListenableSelector( + listenable: _focusModel, + selector: isItemFocused, + builder: (context, isFocused, child) => FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + borderRadius: tokens(context).radiusSm, + focusScale: fullCardLayout ? TvBrowseRailLayout.fullCardFocusScale : FocusTheme.focusScale, + useFocusGlow: fullCardLayout, + // The card draws the border itself (poster rect for + // standard cards, whole card when full-bleed). + delegateFocusBorder: true, + glowSize: fullCardLayout ? Size(metrics.cardWidth, metrics.posterHeight) : null, + onTap: () { + _selectHubItem(hub, hubIndex, itemIndex); + unawaited(_activateCurrentItem()); + }, + onLongPress: metrics.isPersonHub + ? null + : () { + _selectHubItem(hub, hubIndex, itemIndex); + _cardKeyFor(hub, itemIndex).currentState?.showContextMenu(); + }, + child: child!, + ), + // MergeSemantics: one node per card (MediaCard merges + // internally) — the per-frame semantics pass scales with + // node count on TV boxes with an accessibility service. + child: metrics.isPersonHub + ? MergeSemantics( + child: _buildPersonCard( + context, + item, + cardWidth: metrics.cardWidth, + imageSize: metrics.posterHeight, + scale: scale, + fullCardLayout: fullCardLayout, + ), + ) + : MediaCard( + key: _cardKeyFor(hub, itemIndex), + item: item, + width: metrics.cardWidth, + height: metrics.posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + fullBleedImage: fullCardLayout, + isInContinueWatching: _isContinueWatchingHub(hub), + usesContinueWatchingAction: _usesContinueWatchingAction(hub), + mixedHubContext: metrics.isMixedHub, + episodePosterModeOverride: episodePosterMode, + ), + ); + + return Padding( + padding: .only(right: metrics.itemGap), + child: MouseRegion( + onEnter: (_) => _setHoveredItem(hub, itemIndex), + child: Align(alignment: .topLeft, child: focusableCard), + ), + ); + }, + ), + ); + } + Widget _buildPersonCard( BuildContext context, MediaItem item, { @@ -1726,20 +1798,31 @@ class _RailClipper extends CustomClipper { } } -/// (hubIndex, itemIndex) focus position observed by the per-card -/// [ListenableSelector]s. `notify: false` covers build-phase syncs -/// (initState/didUpdateWidget/setState), where notifying would call setState -/// on descendants mid-build and the enclosing rebuild refreshes the selectors -/// anyway. -class _RailFocusPosition extends ChangeNotifier { - (int, int) _value = (0, 0); +/// Hot rail focus state — (hubIndex, itemIndex) position and whether the rail +/// itself holds focus — observed through [ListenableSelector]s so d-pad moves +/// and rail focus flips repaint only the affected cards/headers/dim scrims +/// instead of setState-rebuilding every visible row (expensive on low-end +/// TVs). `notify: false` covers build-phase syncs (initState/didUpdateWidget), +/// where notifying would call setState on descendants mid-build and the +/// enclosing rebuild refreshes the selectors anyway. +class _RailFocusModel extends ChangeNotifier { + (int, int) _position = (0, 0); + bool _railHasFocus = false; - (int, int) get value => _value; + (int, int) get position => _position; + int get hubIndex => _position.$1; + bool get railHasFocus => _railHasFocus; void set(int hubIndex, int itemIndex, {bool notify = true}) { final next = (hubIndex, itemIndex); - if (next == _value) return; - _value = next; + if (next == _position) return; + _position = next; if (notify) notifyListeners(); } + + void setRailFocus(bool value) { + if (value == _railHasFocus) return; + _railHasFocus = value; + notifyListeners(); + } }