fix(tvos): match Siri Remote navigation to measured native focus-engine physics

Siri Remote navigation felt sluggish and then over-sensitive next to
native tvOS apps (issue #2006): swipes were priced at a fixed travel
per step, a single flick could glide into a second focus step, hard
lifts coasted several extrapolated steps, and rail scrolls snapped in
65-250ms where native glides.

Retuned the whole path against two hardware instrumentation passes on
an Apple TV 4K: committed-move telemetry through an experimental
UIFocusItem bridge (branch feat/tvos-native-focus-bridge), then a
dedicated native probe app logging every touch sample, pan velocity,
engine hint, focus step, and scroll tick across 101 swipe sessions on
160/230/300pt tiles. What the data showed, now encoded:

- step pricing follows geometry: one step costs the focused item's
  extent along the swipe axis plus ~155pt (measured 314/391/410pt on
  160/230/300pt tiles), not a fixed distance. Thresholds derive per
  axis from the primary focus rect, normalized so a wide-flat control
  steps vertically once the finger covers its height. Locked-focus
  rows (hub rows, the TV browse rail) vend their selected card's rect
  through the new LockedFocusRowNode so the row-wide focus node's
  screen-sized rect never prices the step. Scopes, the player's
  catch-all surfaces, and unbuilt cards fall back to a fixed 400pt.
- a lift never coasts more than one step: sessions with lift
  velocities up to ~11400pt/s never produced a second coast step. The
  glide is gated on a sustained drag (two consecutive same-direction
  steps), cancelled by reversal pivots and new touches, so a discrete
  flick moves exactly one item.
- the native 'inertia' feel is the scroll animation, not focus
  physics: the engine's scrollable containers settle over ~450-900ms
  of ease-out. TV rail and hub-row navigation scrolls now retarget a
  500ms easeOutCubic animation per step, so drags and hold-repeats
  chain into one continuous glide that catches up on release.
This commit is contained in:
edde746
2026-08-24 13:41:44 +02:00
parent 0fdc22b4fa
commit f357be4077
6 changed files with 379 additions and 101 deletions
+20
View File
@@ -33,6 +33,26 @@ class DirectionalShortcutFocusNode extends FocusNode {
node is DirectionalShortcutFocusNode && node.consumesDirectionalKeys(key);
}
/// A [FocusNode] for a locked-focus row: the node spans the whole row while
/// its owner steps an internal selection index between the row's items.
///
/// Swipe-step pricing follows the focused *item's* geometry (see
/// `AppleTvRemoteTouchService`), and for a locked-focus row the node's own
/// rect is the row — screen-wide — which would price a swipe step at the
/// travel cap and make the row feel dead. The owner instead vends the
/// selected item's global rect here; a null return (item not built yet,
/// unknown geometry) falls back to the fixed step distance.
///
/// Like [DirectionalShortcutFocusNode], the fact rides on the node because it
/// depends on what has focus, not on where a widget sits.
class LockedFocusRowNode extends FocusNode {
LockedFocusRowNode({required this.focusedItemRect, super.debugLabel, super.skipTraversal});
/// Global rect of the row's currently selected item, evaluated per swipe
/// frame so selection moves need no syncing.
final Rect? Function() focusedItemRect;
}
/// Whether [event] is evidence that the viewer wants to navigate by focus.
///
/// This is the single answer to two questions that must never disagree:
+125 -48
View File
@@ -1,7 +1,9 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../focus/focus_navigation_intent.dart';
import '../focus/input_mode_tracker.dart';
import '../utils/app_logger.dart';
import '../utils/key_event_simulator.dart' as key_sim;
@@ -17,31 +19,59 @@ class AppleTvRemotePlayPauseAction {
}
const double _axisSwitchDominanceRatio = 1.5;
// Retuned against the native tvOS focus engine, measured on-device on an
// Apple TV 4K (issue #2006), poster grid 230x345:
// - one focus step per ~400pt of indirect-touch travel (UITouch points — the
// same accelerated space the engine reports on this channel), measured
// identical for the 230pt and 345pt axes: the step distance does NOT
// follow the focused item's extent;
// - steps repeat every ~60ms (native median) during a committed drag;
// - a fast lift glides on: one extra step past ~2000pt/s, two past ~8000pt/s,
// landing within ~130ms of the lift.
// Small extents (chips, list rows) are unmeasured; the fixed step distance
// extrapolates the measured axis-independence to them.
// Tuned against the native tvOS focus engine. Two instrumentation passes on
// an Apple TV 4K (issue #2006):
// 1. committed-move telemetry through the experimental UIFocusItem bridge
// (branch feat/tvos-native-focus-bridge);
// 2. a dedicated native probe app logging every touch sample, pan velocity,
// engine hint, and focus step across 101 swipe sessions on 160/230/300pt
// tiles (FocusProbe, 2026-08).
// Findings these constants encode:
// - one focus step prices at the focused item's extent along the swipe axis
// plus ~155pt of indirect-touch travel (UITouch points — the accelerated
// space this channel reports): measured 314pt on a 160pt tile, 391pt on a
// 230pt poster, 410pt on a 300pt rail card. The earlier "fixed 400pt"
// reading came from a single poster grid whose two extents both happened
// to price near 400.
// - steps repeat at 40-120ms (mode ~80ms) during a committed drag; the 60ms
// cooldown only floors that cadence, travel does the pricing.
// - a lift never coasts more than ONE step: sessions with lift velocities
// up to ~11400pt/s produced 95 zero-coast lifts and 6 single-step coasts
// landing 4-101ms after the lift. Long flat-cadence step bursts in
// earlier logs were dpad hold-repeat key events, not swipe inertia. Do
// not reintroduce a multi-step momentum law.
const Duration _swipeRepeatInterval = Duration(milliseconds: 60);
// Fallback step travel when no usable focus geometry exists (a bare focus
// scope, the player's screen-sized catch-all surfaces, a detached node).
const double _swipeStepDistance = 400;
// Travel added to the focused item's extent to price one step.
const double _swipeStepExtraTravel = 155;
// Guards, not tuning: measured extents span 90-345pt; anything outside
// prices within sane bounds instead of extrapolating the affine law.
const double _minSwipeStepTravel = 200;
const double _maxSwipeStepTravel = 700;
const Duration _glideStepInterval = Duration(milliseconds: 70);
const double _glideVelocity = 2000;
const double _glideDoubleStepVelocity = 8000;
const Duration _liftVelocityWindow = Duration(milliseconds: 100);
// A glide only ever extends a sustained drag: the gesture must already have
// emitted this many consecutive steps in the lift direction. The velocity
// estimator runs in the same accelerated space as the step distance, and an
// ordinary discrete flick covers one step's travel at well past
// [_glideVelocity] there — ungated, every deliberate single swipe glided
// into a second step (issue #2006 follow-up reports).
const int _glideMinConsecutiveSteps = 2;
/// Bridges tvOS touch-surface events (Siri Remote and Apple's iOS Remote app)
/// into the focus-tree key events Plezy already handles for D-pad navigation.
///
/// One focus step costs a fixed [swipeThreshold] of touch travel regardless
/// of the focused control's size, steps repeat on a short cadence during a
/// sustained drag, and a fast lift "glides" one or two further steps — all
/// three tuned to on-device measurements of the native focus engine.
/// Like the native focus engine, one focus step prices by on-screen geometry:
/// the focused control's extent along the swipe axis plus a fixed travel
/// margin, falling back to [swipeThreshold] when no usable geometry exists.
/// Steps repeat on a short cadence during a sustained drag, and a fast lift
/// "glides" at most one further step — never more; the native engine has no
/// multi-step swipe inertia. A glide only extends a drag that already covered
/// [_glideMinConsecutiveSteps] steps in that direction, so a discrete flick
/// moves exactly one item.
class AppleTvRemoteTouchService {
static const String _channelName = 'flutter/gamepadtouchevent';
@@ -56,9 +86,14 @@ class AppleTvRemoteTouchService {
final StreamController<AppleTvRemotePlayPauseAction> _playPauseController =
StreamController<AppleTvRemotePlayPauseAction>.broadcast();
/// Touch travel that prices one focus step.
/// Fallback touch travel that prices one focus step when no usable focus
/// geometry exists.
final double swipeThreshold;
/// Global rect of the control that prices a focus step, or null when no
/// usable geometry exists. Injected so tests can supply fake geometry.
final Rect? Function() _focusedItemRect;
bool _listening = false;
bool _nativeKeyHandlerRegistered = false;
bool _touchActive = false;
@@ -69,6 +104,7 @@ class AppleTvRemoteTouchService {
_SwipeAxis? _lastSwipeAxis;
DateTime? _lastSwipeAt;
LogicalKeyboardKey? _lastSwipeKey;
int _consecutiveStepCount = 0;
final List<({DateTime t, double x, double y})> _moveSamples = [];
Timer? _glideTimer;
@@ -77,9 +113,11 @@ class AppleTvRemoteTouchService {
VoidCallback? scheduleFrame,
DateTime Function()? now,
this.swipeThreshold = _swipeStepDistance,
Rect? Function()? focusedItemRect,
}) : _simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
_now = now ?? DateTime.now,
_focusedItemRect = focusedItemRect ?? _defaultFocusedItemRect,
_duplicateInputGuard = GamepadDuplicateInputGuard(now: now);
Stream<AppleTvRemotePlayPauseAction> get playPauseActions => _playPauseController.stream;
@@ -177,6 +215,7 @@ class AppleTvRemoteTouchService {
_lastSwipeAxis = null;
_lastSwipeAt = null;
_lastSwipeKey = null;
_consecutiveStepCount = 0;
_moveSamples
..clear()
..add((t: _now(), x: x, y: y));
@@ -211,7 +250,8 @@ class AppleTvRemoteTouchService {
return;
}
final axis = _resolveSwipeAxis(x: x, y: y, deltaX: deltaX, deltaY: deltaY);
final thresholds = _stepThresholds();
final axis = _resolveSwipeAxis(x: x, y: y, deltaX: deltaX, deltaY: deltaY, thresholds: thresholds);
if (axis == null) return;
final logicalKey = axis == _SwipeAxis.horizontal
@@ -223,33 +263,39 @@ class AppleTvRemoteTouchService {
source: 'swipe',
detail:
'dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)} '
'th=${_formatDouble(swipeThreshold)}',
'thX=${_formatDouble(thresholds.horizontal)} thY=${_formatDouble(thresholds.vertical)}',
);
_anchorX = x;
_anchorY = y;
_lastSwipeAxis = axis;
_lastSwipeAt = now;
_consecutiveStepCount = logicalKey == _lastSwipeKey ? _consecutiveStepCount + 1 : 1;
_lastSwipeKey = logicalKey;
}
/// Resolves which axis, if any, covered a full step distance, with
/// hysteresis so incidental drift does not zig-zag an established swipe.
/// Resolves which axis, if any, covered a full step, with hysteresis so
/// incidental drift does not zig-zag an established swipe.
///
/// Distances are normalized by the per-axis thresholds so that, like the
/// native focus engine, a wide-flat control steps vertically once the
/// finger covers its height even while the raw horizontal delta is larger.
_SwipeAxis? _resolveSwipeAxis({
required double x,
required double y,
required double deltaX,
required double deltaY,
required ({double horizontal, double vertical}) thresholds,
}) {
final progressX = deltaX.abs() / swipeThreshold;
final progressY = deltaY.abs() / swipeThreshold;
final progressX = deltaX.abs() / thresholds.horizontal;
final progressY = deltaY.abs() / thresholds.vertical;
if (progressX < 1 && progressY < 1) return null;
final candidate = progressX >= progressY ? _SwipeAxis.horizontal : _SwipeAxis.vertical;
final lastAxis = _lastSwipeAxis;
if (lastAxis == null || candidate == lastAxis) return candidate;
final totalProgressX = (_startX - x).abs() / swipeThreshold;
final totalProgressY = (_startY - y).abs() / swipeThreshold;
final totalProgressX = (_startX - x).abs() / thresholds.horizontal;
final totalProgressY = (_startY - y).abs() / thresholds.vertical;
final candidateTotal = _axisValue(candidate, totalProgressX, totalProgressY);
final lastAxisTotal = _axisValue(lastAxis, totalProgressX, totalProgressY);
final candidateSegment = _axisValue(candidate, progressX, progressY);
@@ -266,6 +312,39 @@ class AppleTvRemoteTouchService {
return axis == _SwipeAxis.horizontal ? horizontal : vertical;
}
({double horizontal, double vertical}) _stepThresholds() {
final rect = _focusedItemRect();
if (rect == null) return (horizontal: swipeThreshold, vertical: swipeThreshold);
return (horizontal: _thresholdForExtent(rect.width), vertical: _thresholdForExtent(rect.height));
}
double _thresholdForExtent(double extent) {
if (!extent.isFinite || extent <= 0) return swipeThreshold;
return (extent + _swipeStepExtraTravel).clamp(_minSwipeStepTravel, _maxSwipeStepTravel).toDouble();
}
/// Reads the primary focus geometry, rejecting nodes whose rect cannot
/// meaningfully price a step: bare scopes (nothing real is focused yet) and
/// the player's catch-all [DirectionalShortcutFocusNode] surfaces are
/// screen-sized, and a detached or unlaid-out node has no rect at all. A
/// locked-focus row's node is also row-sized; it vends the selected item's
/// rect instead ([LockedFocusRowNode]).
static Rect? _defaultFocusedItemRect() {
final node = FocusManager.instance.primaryFocus;
if (node == null || node is FocusScopeNode || node is DirectionalShortcutFocusNode) return null;
if (node is LockedFocusRowNode) return _validRectOrNull(node.focusedItemRect());
final context = node.context;
if (context == null) return null;
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox || !renderObject.attached || !renderObject.hasSize) return null;
return _validRectOrNull(node.rect);
}
static Rect? _validRectOrNull(Rect? rect) {
if (rect == null || !rect.isFinite || rect.isEmpty) return null;
return rect;
}
void _recordMoveSample(DateTime now, double x, double y) {
_moveSamples.add((t: now, x: x, y: y));
final cutoff = now.subtract(_liftVelocityWindow);
@@ -276,45 +355,42 @@ class AppleTvRemoteTouchService {
void _endTouch() {
final glideKey = _lastSwipeKey;
final glideSteps = _liftGlideSteps();
final shouldGlide = _liftShouldGlide();
_resetTouch();
if (glideKey != null && glideSteps > 0) _startGlide(glideKey, glideSteps);
if (glideKey != null && shouldGlide) _startGlide(glideKey);
}
/// Prices the post-lift glide from the finger's velocity over the last
/// [_liftVelocityWindow] of the gesture, measured along the established
/// swipe axis. A gesture that never produced a step has no established
/// direction and never glides; neither does a lift moving against the last
/// step (a reversal pivot).
int _liftGlideSteps() {
/// Decides whether the lift glides one — and only one — further step, from
/// the finger's velocity over the last [_liftVelocityWindow] of the
/// gesture, measured along the established swipe axis. Only a sustained
/// drag glides: the gesture must already have emitted
/// [_glideMinConsecutiveSteps] consecutive steps in the lift direction, so
/// a discrete one-step flick never overshoots its target. A gesture that
/// never produced a step has no established direction and never glides;
/// neither does a lift moving against the last step (a reversal pivot).
bool _liftShouldGlide() {
final key = _lastSwipeKey;
final axis = _lastSwipeAxis;
if (key == null || axis == null || _moveSamples.length < 2) return 0;
if (key == null || axis == null || _moveSamples.length < 2) return false;
if (_consecutiveStepCount < _glideMinConsecutiveSteps) return false;
final first = _moveSamples.first;
final last = _moveSamples.last;
final dt = last.t.difference(first.t).inMicroseconds / Duration.microsecondsPerSecond;
if (dt <= 0) return 0;
if (dt <= 0) return false;
final velocity = axis == _SwipeAxis.horizontal ? (last.x - first.x) / dt : (last.y - first.y) / dt;
final towardKey = axis == _SwipeAxis.horizontal
? (velocity < 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight)
: (velocity < 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown);
if (towardKey != key) return 0;
final speed = velocity.abs();
if (speed < _glideVelocity) return 0;
return speed >= _glideDoubleStepVelocity ? 2 : 1;
if (towardKey != key) return false;
return velocity.abs() >= _glideVelocity;
}
void _startGlide(LogicalKeyboardKey key, int steps) {
void _startGlide(LogicalKeyboardKey key) {
_cancelGlide();
var remaining = steps;
_log('start glide key=${_keyName(key)} steps=$steps');
_glideTimer = Timer.periodic(_glideStepInterval, (timer) {
_log('start glide key=${_keyName(key)}');
_glideTimer = Timer(_glideStepInterval, () {
_glideTimer = null;
_emitKey(key, source: 'glide');
remaining -= 1;
if (remaining <= 0) {
timer.cancel();
if (identical(_glideTimer, timer)) _glideTimer = null;
}
});
}
@@ -341,6 +417,7 @@ class AppleTvRemoteTouchService {
_lastSwipeAxis = null;
_lastSwipeAt = null;
_lastSwipeKey = null;
_consecutiveStepCount = 0;
_moveSamples.clear();
}
+20 -4
View File
@@ -216,6 +216,8 @@ void scrollListToIndex(
required double itemExtent,
double leadingPadding = 12.0,
bool animate = true,
Duration duration = const Duration(milliseconds: 150),
Curve curve = Curves.easeOut,
}) {
if (controller.positions.length != 1 || itemExtent <= 0) return;
@@ -226,7 +228,7 @@ void scrollListToIndex(
final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, maxExtent);
if (animate) {
unawaited(controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut));
unawaited(controller.animateTo(desiredOffset, duration: duration, curve: curve));
} else {
controller.jumpTo(desiredOffset);
}
@@ -240,6 +242,8 @@ void scrollKeyedChildToHorizontalCenter(
bool animate = true,
int maxAttempts = 2,
bool Function()? isCurrent,
Duration duration = const Duration(milliseconds: 150),
Curve curve = Curves.easeOut,
}) {
void schedule(int attempt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -251,7 +255,13 @@ void scrollKeyedChildToHorizontalCenter(
return;
}
final didResolve = _scrollContextToHorizontalCenterNow(controller, context, animate: animate);
final didResolve = _scrollContextToHorizontalCenterNow(
controller,
context,
animate: animate,
duration: duration,
curve: curve,
);
if (!didResolve && attempt < maxAttempts) schedule(attempt + 1);
});
}
@@ -259,7 +269,13 @@ void scrollKeyedChildToHorizontalCenter(
schedule(0);
}
bool _scrollContextToHorizontalCenterNow(ScrollController controller, BuildContext context, {required bool animate}) {
bool _scrollContextToHorizontalCenterNow(
ScrollController controller,
BuildContext context, {
required bool animate,
required Duration duration,
required Curve curve,
}) {
if (!context.mounted || controller.positions.length != 1) return false;
final position = controller.position;
@@ -286,7 +302,7 @@ bool _scrollContextToHorizontalCenterNow(ScrollController controller, BuildConte
if ((target - position.pixels).abs() < 0.5) return true;
if (animate) {
unawaited(controller.animateTo(target, duration: const Duration(milliseconds: 150), curve: Curves.easeOut));
unawaited(controller.animateTo(target, duration: duration, curve: curve));
} else {
controller.jumpTo(target);
}
+21 -3
View File
@@ -6,6 +6,7 @@ import '../focus/dpad_navigator.dart';
import '../focus/dpad_select_long_press_controller.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/focus_navigation_intent.dart';
import '../focus/key_event_utils.dart';
import '../services/settings_service.dart';
import 'settings_builder.dart';
@@ -149,12 +150,16 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
return serverId == null ? widget.hub.id : '$serverId:${widget.hub.id}';
}
// Native tvOS focus-engine scroll settles over ~450-900ms of ease-out
// (FocusProbe capture, issue #2006); successive steps retarget the
// animation so a drag chains into one continuous glide.
static const _navigationScrollDuration = Duration(milliseconds: 500);
final _selectLongPress = DpadSelectLongPressController();
@override
void initState() {
super.initState();
_hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.id}');
_hubFocusNode = LockedFocusRowNode(debugLabel: 'hub_${widget.hub.id}', focusedItemRect: _focusedItemRect);
_hubFocusNode.addListener(_onFocusChange);
}
@@ -261,8 +266,8 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
Scrollable.ensureVisible(
context,
alignment: widget.focusScrollAlignment,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
duration: _navigationScrollDuration,
curve: Curves.easeOutCubic,
);
});
}
@@ -278,6 +283,8 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
itemExtent: _itemExtent,
leadingPadding: _leadingPadding,
animate: animate,
duration: _navigationScrollDuration,
curve: Curves.easeOutCubic,
);
if (index >= 0 && index < _totalItemCount) {
scrollKeyedChildToHorizontalCenter(
@@ -285,6 +292,8 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
_itemKeyFor(index),
animate: animate,
isCurrent: () => _focusedIndex == index && index < _totalItemCount,
duration: _navigationScrollDuration,
curve: Curves.easeOutCubic,
);
}
}
@@ -376,6 +385,15 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
return _itemKeys.putIfAbsent(index, () => GlobalKey());
}
/// Global rect of the selected card, pricing one swipe step by the card's
/// geometry instead of the row-wide focus node's (see [LockedFocusRowNode]).
Rect? _focusedItemRect() {
final context = _itemKeys[_focusedIndex]?.currentContext;
final box = context?.findRenderObject();
if (box is! RenderBox || !box.attached || !box.hasSize) return null;
return box.localToGlobal(Offset.zero) & box.size;
}
GlobalKey<MediaCardState> _getMediaCardKey(int index) {
return _mediaCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
}
+25 -11
View File
@@ -10,6 +10,7 @@ import '../focus/dpad_navigator.dart';
import '../focus/dpad_select_long_press_controller.dart';
import '../focus/focus_theme.dart';
import '../focus/key_event_utils.dart';
import '../focus/focus_navigation_intent.dart';
import '../focus/locked_hub_controller.dart';
import '../i18n/strings.g.dart';
import '../media/ids.dart';
@@ -359,14 +360,18 @@ class TvBrowseRail extends StatefulWidget {
}
class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixin {
static const _navigationScrollDuration = Duration(milliseconds: 130);
static const _repeatNavigationScrollDuration = Duration(milliseconds: 65);
// Native tvOS focus-engine scroll settles over ~450-900ms of ease-out
// (FocusProbe capture, issue #2006). Successive steps — including
// hold-repeats — retarget the animation, so a sustained drag trails the
// focused index slightly and catches up in one continuous glide on
// release, exactly like the native engine's scrollable containers.
static const _navigationScrollDuration = Duration(milliseconds: 500);
static const _scrollCatchUpViewportDistance = 2.5;
// Equivalent to the former whole-rail Opacity(0.6) without keeping a
// full-viewport saveLayer alive.
static const _unfocusedRailDimAlpha = 0.4;
final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail');
late final FocusNode _focusNode = LockedFocusRowNode(debugLabel: 'tv_browse_rail', focusedItemRect: _focusedCardRect);
final Map<String, ScrollController> _scrollControllers = {};
final ScrollController _verticalController = ScrollController();
final SnapshotController _verticalScrollSnapshotController = SnapshotController();
@@ -670,7 +675,7 @@ class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixi
if (key.isLeftKey) {
if (_itemIndex > 0) {
_moveItem(-1, duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration);
_moveItem(-1);
} else {
widget.onNavigateToSidebar?.call();
}
@@ -679,7 +684,7 @@ class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixi
if (key.isRightKey) {
if (_itemIndex < _totalItemCount(hub) - 1) {
_moveItem(1, duration: event is KeyRepeatEvent ? _repeatNavigationScrollDuration : _navigationScrollDuration);
_moveItem(1);
}
return KeyEventResult.handled;
}
@@ -753,11 +758,8 @@ class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixi
final target = _sectionOffsets[_hubIndex].clamp(0.0, _sectionMaxScrollExtent).toDouble();
if (animate) {
_startVerticalScrollAnimation(
() => _verticalController.animateTo(
target,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
),
() =>
_verticalController.animateTo(target, duration: _navigationScrollDuration, curve: Curves.easeOutCubic),
);
} else {
_verticalScrollGeneration++;
@@ -776,7 +778,7 @@ class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixi
() => Scrollable.ensureVisible(
context,
alignment: 0,
duration: const Duration(milliseconds: 250),
duration: _navigationScrollDuration,
curve: Curves.easeOutCubic,
),
);
@@ -924,6 +926,18 @@ class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixi
return _mediaCardKeys.putIfAbsent('${_hubKey(hub)}:$itemIndex', () => GlobalKey<MediaCardState>());
}
/// Global rect of the active hub's selected card, pricing one swipe step by
/// the card's geometry instead of the rail-wide focus node's (see
/// [LockedFocusRowNode]).
Rect? _focusedCardRect() {
final hub = _activeHub;
if (hub == null || _itemIndex < 0 || _itemIndex >= hub.items.length) return null;
final context = _cardKeyFor(hub, _itemIndex).currentContext;
final box = context?.findRenderObject();
if (box is! RenderBox || !box.attached || !box.hasSize) return null;
return box.localToGlobal(Offset.zero) & box.size;
}
bool _isContinueWatchingHub(MediaHub hub) => widget.isContinueWatchingHub?.call(hub) ?? false;
bool _usesContinueWatchingAction(MediaHub hub) {
@@ -2,7 +2,9 @@ import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focus_navigation_intent.dart';
import 'package:plezy/services/apple_tv_remote_touch_service.dart';
void main() {
@@ -69,6 +71,60 @@ void main() {
expect(harness.keys, [LogicalKeyboardKey.arrowUp]);
});
test('prices a step by the focused item extent plus the travel margin', () async {
final harness = _Harness()..focusedRect = const Rect.fromLTWH(0, 0, 245, 10);
// Horizontal: 245pt extent + 155pt margin = 400pt per step.
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 101, y: 500);
expect(harness.keys, isEmpty);
await harness.send('move', x: 100, y: 500);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
// Vertical: 10pt extent prices at the 200pt floor, not at 165pt.
harness.advance(const Duration(milliseconds: 61));
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 500, y: 301);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
await harness.send('move', x: 500, y: 300);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowUp]);
});
test('a wide-flat control steps vertically once the finger covers its height', () async {
final harness = _Harness()..focusedRect = const Rect.fromLTWH(0, 0, 900, 45);
// Width prices at the 700pt cap; height at 45+155 = 200pt. A larger raw
// horizontal delta still resolves vertical because axis progress is
// normalized by the per-axis thresholds, like the native engine.
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 250, y: 290);
expect(harness.keys, [LogicalKeyboardKey.arrowUp]);
});
testWidgets('a locked-focus row prices by its selected item, not its row-wide node', (tester) async {
final node = LockedFocusRowNode(debugLabel: 'row', focusedItemRect: () => const Rect.fromLTWH(0, 0, 245, 345));
addTearDown(node.dispose);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Focus(focusNode: node, child: const SizedBox(width: 1700, height: 400)),
),
);
node.requestFocus();
await tester.pump();
final keys = <LogicalKeyboardKey>[];
// No injected geometry: exercises the default primary-focus resolution,
// which must consult the node's item rect (245+155 = 400pt per step)
// instead of its 1700pt-wide row rect (the 700pt travel cap).
final service = AppleTvRemoteTouchService(simulateKeyPress: keys.add, scheduleFrame: () {});
await service.handleMessage({'type': 'started', 'x': 500.0, 'y': 500.0});
await service.handleMessage({'type': 'move', 'x': 101.0, 'y': 500.0});
expect(keys, isEmpty);
await service.handleMessage({'type': 'move', 'x': 100.0, 'y': 500.0});
expect(keys, [LogicalKeyboardKey.arrowLeft]);
});
test('keeps horizontal axis through non-decisive vertical drift', () async {
final harness = _Harness();
@@ -192,40 +248,44 @@ void main() {
expect(harness.keys, isEmpty);
});
test('a fast lift glides one extra step', () {
fakeAsync((async) {
final harness = _Harness();
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 50));
// 120pt in 50ms = 2400pt/s at lift: past the glide velocity, below
// the double-step velocity.
harness.sendSync('move', x: 380, y: 500);
harness.sendSync('ended', x: 380, y: 500);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
async.elapse(const Duration(milliseconds: 70));
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]);
async.elapse(const Duration(milliseconds: 300));
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]);
});
});
test('a violent flick glides two extra steps', () {
test('a fast single-step flick does not glide', () {
fakeAsync((async) {
final harness = _Harness();
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 8));
// 120pt in 8ms = 15000pt/s at lift: past the double-step velocity.
// 120pt in 8ms = 15000pt/s: far past both glide velocities, but the
// gesture emitted only one step. Ungated, every deliberate single
// swipe glided into a second step (issue #2006).
harness.sendSync('move', x: 380, y: 500);
harness.sendSync('ended', x: 380, y: 500);
async.elapse(const Duration(milliseconds: 500));
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
});
});
async.elapse(const Duration(milliseconds: 140));
test('a fast lift after a sustained drag glides one extra step', () {
fakeAsync((async) {
final harness = _Harness();
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 50));
harness.sendSync('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 60));
harness.sendSync('move', x: 260, y: 500);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
// 120pt in the last 8ms: ~3500pt/s over the velocity window — past
// the glide velocity, below the double-step velocity.
harness.advance(const Duration(milliseconds: 8));
harness.sendSync('move', x: 140, y: 500);
harness.sendSync('ended', x: 140, y: 500);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
async.elapse(const Duration(milliseconds: 70));
expect(harness.keys, List.filled(3, LogicalKeyboardKey.arrowLeft));
async.elapse(const Duration(milliseconds: 300));
@@ -233,18 +293,56 @@ void main() {
});
});
test('a slow lift does not glide', () {
test('a violent lift after a sustained drag still glides only one extra step', () {
fakeAsync((async) {
final harness = _Harness();
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 1));
harness.sendSync('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 61));
harness.sendSync('move', x: 260, y: 500);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
// The drag steps age out of the velocity window; the final
// sub-threshold segment covers 64pt in 5ms = 12800pt/s. The native
// engine never coasts more than one step no matter how violent the
// lift (FocusProbe capture: 101 sessions, lifts up to ~11400pt/s,
// never a second coast step).
harness.advance(const Duration(milliseconds: 103));
harness.sendSync('move', x: 244, y: 500);
harness.advance(const Duration(milliseconds: 5));
harness.sendSync('move', x: 180, y: 500);
harness.sendSync('ended', x: 180, y: 500);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
async.elapse(const Duration(milliseconds: 500));
expect(harness.keys, List.filled(3, LogicalKeyboardKey.arrowLeft));
});
});
test('a slow lift after a sustained drag does not glide', () {
fakeAsync((async) {
final harness = _Harness();
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 60));
// 100pt in 60ms = 1667pt/s at lift: below the glide velocity.
harness.sendSync('move', x: 400, y: 500);
harness.sendSync('ended', x: 400, y: 500);
harness.advance(const Duration(milliseconds: 60));
harness.sendSync('move', x: 300, y: 500);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
// Sub-threshold 90pt in 60ms = 1500pt/s at lift: the drag satisfies
// the glide gate, but the lift stays below the glide velocity.
harness.advance(const Duration(milliseconds: 60));
harness.sendSync('move', x: 210, y: 500);
harness.sendSync('ended', x: 210, y: 500);
async.elapse(const Duration(milliseconds: 500));
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
});
});
@@ -253,13 +351,17 @@ void main() {
final harness = _Harness();
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 8));
harness.advance(const Duration(milliseconds: 50));
harness.sendSync('move', x: 380, y: 500);
harness.sendSync('ended', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 60));
harness.sendSync('move', x: 260, y: 500);
harness.advance(const Duration(milliseconds: 8));
harness.sendSync('move', x: 140, y: 500);
harness.sendSync('ended', x: 140, y: 500);
harness.sendSync('started', x: 500, y: 500);
async.elapse(const Duration(milliseconds: 500));
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
});
});
@@ -285,17 +387,46 @@ void main() {
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 50));
harness.sendSync('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 60));
harness.sendSync('move', x: 260, y: 500);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
// Reversal pivot inside the cooldown: net window velocity points
// right, against the emitted arrowLeft.
harness.advance(const Duration(milliseconds: 8));
harness.sendSync('move', x: 620, y: 500);
harness.sendSync('ended', x: 620, y: 500);
harness.sendSync('move', x: 500, y: 500);
harness.sendSync('ended', x: 500, y: 500);
async.elapse(const Duration(milliseconds: 500));
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
expect(harness.keys, List.filled(2, LogicalKeyboardKey.arrowLeft));
});
});
test('a direction reversal resets the glide gate', () {
fakeAsync((async) {
final harness = _Harness();
harness.sendSync('started', x: 500, y: 500);
harness.advance(const Duration(milliseconds: 50));
harness.sendSync('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 60));
harness.sendSync('move', x: 260, y: 500);
// Reverse: one fast rightward step, then a fast rightward lift. The
// gesture emitted three steps, but only one in the lift direction,
// so a correction never glides past its target.
harness.advance(const Duration(milliseconds: 60));
harness.sendSync('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 8));
harness.sendSync('move', x: 500, y: 500);
harness.sendSync('ended', x: 500, y: 500);
async.elapse(const Duration(milliseconds: 500));
expect(harness.keys, [
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
]);
});
});
});
@@ -305,6 +436,7 @@ class _Harness {
_Harness();
DateTime now = DateTime(2026, 5, 5, 12);
Rect? focusedRect;
final List<LogicalKeyboardKey> keys = [];
late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService(
@@ -312,6 +444,7 @@ class _Harness {
scheduleFrame: () {},
now: () => now,
swipeThreshold: 100,
focusedItemRect: () => focusedRect,
);
Future<void> send(String type, {double x = 0, double y = 0}) {