From f357be4077ad0f144ab80e00397a9bfc58bc4b79 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:39:13 +0200 Subject: [PATCH] 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. --- lib/focus/focus_navigation_intent.dart | 20 ++ .../apple_tv_remote_touch_service.dart | 173 ++++++++++----- lib/utils/scroll_utils.dart | 24 ++- lib/widgets/hub_section.dart | 24 ++- lib/widgets/tv_browse_rail.dart | 36 +++- .../apple_tv_remote_touch_service_test.dart | 203 +++++++++++++++--- 6 files changed, 379 insertions(+), 101 deletions(-) diff --git a/lib/focus/focus_navigation_intent.dart b/lib/focus/focus_navigation_intent.dart index fde052534..e1b21502f 100644 --- a/lib/focus/focus_navigation_intent.dart +++ b/lib/focus/focus_navigation_intent.dart @@ -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: diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 7a6be0973..31c28bb54 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -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 _playPauseController = StreamController.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 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(); } diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index 76de730cc..3ec757337 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -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); } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index c10254c4f..9910c4248 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -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 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 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 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 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 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 _getMediaCardKey(int index) { return _mediaCardKeys.putIfAbsent(index, () => GlobalKey()); } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 5d4231be9..9126ae1b4 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -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 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 _scrollControllers = {}; final ScrollController _verticalController = ScrollController(); final SnapshotController _verticalScrollSnapshotController = SnapshotController(); @@ -670,7 +675,7 @@ class TvBrowseRailState extends State 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 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 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 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 with TickerProviderStateMixi return _mediaCardKeys.putIfAbsent('${_hubKey(hub)}:$itemIndex', () => GlobalKey()); } + /// 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) { diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index fd6b02bc5..5e64592ac 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -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 = []; + // 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 keys = []; late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService( @@ -312,6 +444,7 @@ class _Harness { scheduleFrame: () {}, now: () => now, swipeThreshold: 100, + focusedItemRect: () => focusedRect, ); Future send(String type, {double x = 0, double y = 0}) {