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.
106 lines
5.1 KiB
Dart
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;
|
|
}
|