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:
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
Reference in New Issue
Block a user