Files
plezy/lib/focus/focus_navigation_intent.dart
edde746 f357be4077 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.
2026-08-24 13:41:44 +02:00

106 lines
5.1 KiB
Dart

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// A [FocusNode] whose owner consumes arrow / D-pad keys itself instead of
/// letting them traverse focus — the video player seeks with them.
///
/// Focus-mode detection needs this fact: an arrow pressed while such a node
/// holds focus is not evidence that the viewer wants to navigate by focus, so
/// it must not switch the app into keyboard mode and light up focus chrome
/// everywhere.
///
/// The fact rides on the node rather than on a subtree because it depends on
/// *what has focus*, not on where a widget sits. Every ordinary control, sheet,
/// prompt and OSD button therefore stays a plain [FocusNode] and keeps working.
///
/// Declare a node only when it can hold primary focus **without the viewer
/// having navigated to it** — autofocus, a focus reclaim, a self-heal.
/// Everything reached by traversal is already in keyboard mode, so the marker
/// would be redundant there: sliders, spinners, the TV keyboard and the OSD's
/// own buttons all consume arrows and all correctly stay plain nodes.
class DirectionalShortcutFocusNode extends FocusNode {
DirectionalShortcutFocusNode({required this.consumesDirectionalKeys, super.debugLabel, super.skipTraversal});
/// Evaluated per key press, so live settings and chrome state need no
/// syncing. Takes the key because a feature can own one axis and not the
/// other — the player seeks with Left/Right while Up/Down raises the chrome.
final bool Function(LogicalKeyboardKey key) consumesDirectionalKeys;
static bool ownsDirectionalKeys(FocusNode? node, LogicalKeyboardKey key) =>
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:
/// whether [InputModeTracker] switches to keyboard mode, and whether a key may
/// hand focus to the video player's chrome. Deciding them separately is what
/// produced focus appearing on a control while the app still believed a pointer
/// was driving — focus chrome is mode-gated, so the viewer got an invisible
/// selection.
///
/// Activation (Enter) and dismissal (Escape) from a physical keyboard are *not*
/// navigation: they act on whatever already has focus. Promoting on them arms
/// focus chrome — and hides the desktop cursor — for a viewer who only pressed
/// play.
///
/// [focused] is the node that will receive the event; it defaults to the
/// primary focus. Handlers inside a `Focus.onKeyEvent` pass their own node
/// instead of re-reading the global.
bool eventRequestsFocusNavigation(KeyEvent event, {FocusNode? focused}) {
if (event is! KeyDownEvent) return false;
final key = event.logicalKey;
// Unambiguous traversal on every platform.
if (key == LogicalKeyboardKey.tab) return true;
// Opens a menu that takes focus into a new scope, so it does start a session.
if (key.isContextMenuKey) return true;
// `select` / `gameButtonA` are remote-only whatever the engine claims about
// deviceType; `enter` counts from a non-keyboard device, or on TV where a
// remote's OK legitimately arrives as a keyboard `enter` (the same shape
// pin_entry_dialog.dart already compensates for).
if (key.isSelectKey) {
return event.isTvSelectEvent || (PlatformDetector.isTV() && event.isPhysicalKeyboardEnter);
}
// Remote BACK proves a pointerless device. A physical keyboard's `escape` and
// a media keyboard's `browserBack` only dismiss, so they stay out — matching
// how classifyPlayerNavigationKey already reads a non-keyboard `escape`.
if (key.isBackKey) {
return key == LogicalKeyboardKey.goBack || key == LogicalKeyboardKey.gameButtonB || !event.isPhysicalKeyboardEvent;
}
// Arrows are navigation only where they are not the focused feature's own
// shortcut.
if (key.isDpadDirection) {
return !DirectionalShortcutFocusNode.ownsDirectionalKeys(focused ?? FocusManager.instance.primaryFocus, key);
}
return false;
}