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.
This commit is contained in:
@@ -186,7 +186,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
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<MediaHub>? _tvBrowseHubsCache;
|
||||
(List<MediaItem>, List<MediaHub>, bool, String)? _tvBrowseHubsCacheKey;
|
||||
|
||||
List<MediaHub> get _tvBrowseHubs {
|
||||
final key = (_onDeck, _hubs, _hasMoreContinueWatching, t.discover.continueWatching);
|
||||
if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!;
|
||||
final hubs = <MediaHub>[];
|
||||
if (_onDeck.isNotEmpty) {
|
||||
hubs.add(
|
||||
@@ -202,6 +211,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
);
|
||||
}
|
||||
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
|
||||
_tvBrowseHubsCache = hubs;
|
||||
_tvBrowseHubsCacheKey = key;
|
||||
return hubs;
|
||||
}
|
||||
|
||||
@@ -379,19 +390,34 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
/// 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<MediaItem>, List<MediaHub>, bool, bool, bool, String?) get _renderSignature =>
|
||||
(_onDeck, _hubs, _hasMoreContinueWatching, _isLoading, _areHubsLoading, _discover.errorMessage);
|
||||
|
||||
(List<MediaItem>, List<MediaHub>, 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<DiscoverScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<MediaHub>, bool)? _tvBrowseRailWidgetKey;
|
||||
|
||||
Widget _cachedTvBrowseRail(List<MediaHub> 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<DiscoverScreen>
|
||||
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(
|
||||
|
||||
@@ -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<double>(
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+258
-175
@@ -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<TvBrowseRail> {
|
||||
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<String, ScrollController> _scrollControllers = {};
|
||||
@@ -402,10 +406,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
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<double> _sectionOffsets = const [];
|
||||
double _sectionMaxScrollExtent = 0;
|
||||
Timer? _longPressTimer;
|
||||
@@ -460,7 +465,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
_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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
_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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
}
|
||||
_itemIndex = index;
|
||||
_hasUserChangedItem = true;
|
||||
_focusPosition.set(_hubIndex, _itemIndex);
|
||||
_focusModel.set(_hubIndex, _itemIndex);
|
||||
_rememberFocus(hub);
|
||||
_notifyFocusedItem();
|
||||
}
|
||||
@@ -842,13 +852,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<bool>(
|
||||
listenable: _focusModel,
|
||||
selector: () => _focusModel.railHasFocus,
|
||||
builder: (context, railHasFocus, _) => AnimatedDimScrim(
|
||||
dimmed: !railHasFocus,
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
alpha: _unfocusedRailDimAlpha,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1127,7 +1151,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
}
|
||||
|
||||
Widget _buildHubSectionList({
|
||||
required bool hasFocus,
|
||||
required List<EpisodePosterMode> modes,
|
||||
required List<TvBrowseRailLayoutMetrics> metricsByHub,
|
||||
required List<double> sectionHeights,
|
||||
@@ -1148,9 +1171,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
_buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale),
|
||||
ListenableSelector<bool>(
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
_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<TvBrowseRail> {
|
||||
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<bool>(
|
||||
listenable: _focusPosition,
|
||||
selector: isItemFocused,
|
||||
builder: (context, isFocused, _) =>
|
||||
_buildTrailingSlot(context, hub, hubIndex, itemIndex, isFocused: isFocused, scale: scale),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = hub.items[itemIndex];
|
||||
final focusableCard = ListenableSelector<bool>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
listenable: _focusModel,
|
||||
selector: isItemFocused,
|
||||
builder: (context, isFocused, _) =>
|
||||
_buildTrailingSlot(context, hub, hubIndex, itemIndex, isFocused: isFocused, scale: scale),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = hub.items[itemIndex];
|
||||
final focusableCard = ListenableSelector<bool>(
|
||||
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<Rect> {
|
||||
}
|
||||
}
|
||||
|
||||
/// (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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user