perf(tv): cut semantics work during card navigation

This commit is contained in:
edde746
2026-07-25 08:13:09 +02:00
parent 2b3853a882
commit fb27621c75
6 changed files with 319 additions and 76 deletions
+95 -38
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/rendering.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
@@ -21,6 +22,62 @@ void _logFocusableWrapper(String message) {
TextInputDiagnostics.log('FocusableWrapper', message);
}
/// Applies a visual scale without changing hit-test or semantics geometry.
///
/// Focus scale is paint-only: animating a [Transform] marks the transformed
/// subtree's semantics dirty on every frame, which is costly for dense TV
/// grids. Keeping layout and semantics static preserves the same visible
/// motion without rebuilding the accessibility tree.
class _PaintScale extends SingleChildRenderObjectWidget {
const _PaintScale({required this.scale, required super.child});
final double scale;
@override
RenderObject createRenderObject(BuildContext context) => _RenderPaintScale(scale);
@override
void updateRenderObject(BuildContext context, _RenderPaintScale renderObject) {
renderObject.scale = scale;
}
}
class _RenderPaintScale extends RenderProxyBox {
_RenderPaintScale(double scale) : _scale = scale;
final Matrix4 _transform = Matrix4.identity();
double _scale;
set scale(double value) {
if (_scale == value) return;
_scale = value;
markNeedsPaint();
}
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) return;
if (_scale == 1) {
layer = null;
super.paint(context, offset);
return;
}
_transform
..setIdentity()
..setEntry(0, 0, _scale)
..setEntry(1, 1, _scale)
..setTranslationRaw((1 - _scale) * size.width / 2, (1 - _scale) * size.height / 2, 0);
layer = context.pushTransform(
needsCompositing,
offset,
_transform,
super.paint,
oldLayer: layer is TransformLayer ? layer as TransformLayer? : null,
);
}
}
/// A wrapper widget that makes its child focusable with D-pad navigation support.
///
/// Provides:
@@ -475,46 +532,46 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
controller.duration = duration;
}
final shouldScale = showFocus && !widget.disableScale;
// Keep the card subtree outside the scale builder. Rebuilding media-card
// semantics on every animation tick is substantially more expensive than
// changing the paint transform alone on dense TV grids.
Widget card;
if (widget.delegateFocusBorder) {
card = CardFocusScope(showFocus: showFocus, child: widget.child);
} else {
final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(
isFocused: showFocus,
borderRadius: widget.borderRadius,
radii: widget.borderRadii,
)
: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
radii: widget.borderRadii,
color: widget.focusColor,
);
card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: focusDecoration,
child: widget.child,
);
}
if (widget.useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context),
child: card,
);
}
inner = AnimatedBuilder(
animation: _scaleAnimation!,
builder: (context, child) {
final shouldScale = showFocus && !widget.disableScale;
// The glow (full-bleed cards) is drawn in an overlay above siblings so
// it stays symmetric; the in-card decoration only carries the border.
Widget card;
if (widget.delegateFocusBorder) {
card = CardFocusScope(showFocus: showFocus, child: widget.child);
} else {
final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(
isFocused: showFocus,
borderRadius: widget.borderRadius,
radii: widget.borderRadii,
)
: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
radii: widget.borderRadii,
color: widget.focusColor,
);
card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: focusDecoration,
child: widget.child,
);
}
if (widget.useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context),
child: card,
);
}
return Transform.scale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: card);
},
child: card,
builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!),
);
}
+3 -1
View File
@@ -303,7 +303,9 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
final semanticLabel = mediaCardSemanticLabel(item);
final enableDetailLinks = widget.onTap == null;
final preserveDetailSemantics = enableDetailLinks && item is MediaItem && _hasPointerDetailLinks(item);
final preservePointerDetailSemantics = !PlatformDetector.isTV() || MediaQuery.accessibleNavigationOf(context);
final preserveDetailSemantics =
preservePointerDetailSemantics && enableDetailLinks && item is MediaItem && _hasPointerDetailLinks(item);
final localPosterPath = _getLocalPosterPath(context, item);
Widget cardWidget = viewMode == ViewMode.grid
+69 -36
View File
@@ -18,6 +18,7 @@ import '../navigation/main_screen_scope.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../screens/hub_detail_screen.dart';
import '../services/device_performance.dart';
import '../services/settings_service.dart';
import '../theme/mono_tokens.dart';
import '../utils/media_image_helper.dart';
@@ -407,6 +408,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail');
final Map<String, ScrollController> _scrollControllers = {};
final ScrollController _verticalController = ScrollController();
final SnapshotController _verticalScrollSnapshotController = SnapshotController();
final Map<int, GlobalKey> _hubSectionKeys = {};
final Map<String, GlobalKey<MediaCardState>> _mediaCardKeys = {};
final Map<String, TvBrowseRailLayoutMetrics> _metricsByHub = {};
@@ -429,6 +431,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
VoidCallback? _gestureSignalListener;
bool _suppressSelectUntilKeyUp = false;
bool _hasUserChangedHub = false;
int _verticalScrollGeneration = 0;
bool _hasUserChangedItem = false;
MediaHub? get _activeHub => widget.hubs.isEmpty ? null : widget.hubs[_hubIndex.clamp(0, widget.hubs.length - 1)];
@@ -610,6 +613,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
controller.dispose();
}
_verticalController.dispose();
_verticalScrollGeneration++;
_verticalScrollSnapshotController.dispose();
super.dispose();
}
@@ -797,14 +802,16 @@ class TvBrowseRailState extends State<TvBrowseRail> {
if (_verticalController.hasClients && _hubIndex >= 0 && _hubIndex < _sectionOffsets.length) {
final target = _sectionOffsets[_hubIndex].clamp(0.0, _sectionMaxScrollExtent).toDouble();
if (animate) {
unawaited(
_verticalController.animateTo(
_startVerticalScrollAnimation(
() => _verticalController.animateTo(
target,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
),
);
} else {
_verticalScrollGeneration++;
_verticalScrollSnapshotController.allowSnapshotting = false;
_verticalController.jumpTo(target);
}
return;
@@ -813,18 +820,39 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final key = _hubSectionKeys[_hubIndex];
final context = key?.currentContext;
if (context == null) return;
unawaited(
Scrollable.ensureVisible(
context,
alignment: 0,
duration: animate ? const Duration(milliseconds: 250) : Duration.zero,
curve: Curves.easeOutCubic,
),
);
if (animate) {
_startVerticalScrollAnimation(
() => Scrollable.ensureVisible(
context,
alignment: 0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
),
);
} else {
_verticalScrollGeneration++;
_verticalScrollSnapshotController.allowSnapshotting = false;
unawaited(Scrollable.ensureVisible(context, alignment: 0, duration: Duration.zero));
}
});
}
void _startVerticalScrollAnimation(Future<void> Function() animate) {
final generation = ++_verticalScrollGeneration;
// Full-tier row effects must stay live. The reduced tier has already
// resolved those short effects to their final state before this snapshot.
final useSnapshots = DevicePerformance.isReduced;
if (useSnapshots) {
_verticalScrollSnapshotController.allowSnapshotting = true;
}
unawaited(
animate().whenComplete(() {
if (!mounted || generation != _verticalScrollGeneration) return;
_verticalScrollSnapshotController.allowSnapshotting = false;
}),
);
}
void _setHoveredItem(MediaHub hub, int index) {
final active = _activeHub;
if (active == null || _hubKey(active) != _hubKey(hub) || index >= hub.items.length || _itemIndex == index) {
@@ -1195,31 +1223,36 @@ class TvBrowseRailState extends State<TvBrowseRail> {
// below is passed through as a stable child.
bool isActiveHub() => _focusModel.hubIndex == hubIndex;
return SizedBox(
key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()),
height: sectionHeight,
child: Column(
crossAxisAlignment: .stretch,
children: [
ListenableSelector<bool>(
listenable: _focusModel,
selector: isActiveHub,
builder: (context, isActive, _) =>
_buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale),
),
SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)),
_buildHubRail(
hub: hub,
hubIndex: hubIndex,
episodePosterMode: modes[hubIndex],
metrics: metrics,
scale: scale,
fullCardLayout: fullCardLayout,
leftOverflow: leftOverflow,
interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth,
),
],
return SnapshotWidget(
controller: _verticalScrollSnapshotController,
mode: SnapshotMode.permissive,
autoresize: true,
child: SizedBox(
key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()),
height: sectionHeight,
child: Column(
crossAxisAlignment: .stretch,
children: [
ListenableSelector<bool>(
listenable: _focusModel,
selector: isActiveHub,
builder: (context, isActive, _) =>
_buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale),
),
SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)),
_buildHubRail(
hub: hub,
hubIndex: hubIndex,
episodePosterMode: modes[hubIndex],
metrics: metrics,
scale: scale,
fullCardLayout: fullCardLayout,
leftOverflow: leftOverflow,
interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth,
),
],
),
),
);
},