diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 30eb1c63..04122969 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; @@ -18,11 +20,23 @@ class AppleTvRemotePlayPauseAction { /// Bridges tvOS touch-surface events from Apple's iOS Remote app into the /// focus-tree key events Plezy already handles for D-pad navigation. +/// +/// Like the native focus engine, the pan distance for one focus step follows +/// the focused control's on-screen size: small chips traverse quickly, large +/// cards demand a longer swipe. When no usable geometry exists (a bare focus +/// scope, the video player's screen-sized catch-all surfaces) the step falls +/// back to a fixed threshold. class AppleTvRemoteTouchService { static const String _channelName = 'flutter/gamepadtouchevent'; static const double defaultSwipeThreshold = 180; static const double defaultAxisSwitchDominanceRatio = 1.5; static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140); + // Device-tuned on an Apple TV 4K against native focus feel: UIKit's + // indirect-touch acceleration means roughly half an item's extent of + // reported travel already reads as "one deliberate swipe". + static const double defaultSwipeExtentGain = 0.55; + static const double defaultMinSwipeThreshold = 50; + static const double defaultMaxSwipeThreshold = 180; static final AppleTvRemoteTouchService instance = AppleTvRemoteTouchService(); @@ -37,10 +51,22 @@ class AppleTvRemoteTouchService { final void Function() reportNonPointerInput; final StreamController _playPauseController = StreamController.broadcast(); + + /// Fallback step distance when no usable focus geometry exists. final double swipeThreshold; final double axisSwitchDominanceRatio; final Duration swipeRepeatInterval; + /// Multiplier from the focused control's extent to the pan distance for one + /// step. Empirical; see the default constants for the device calibration. + final double swipeExtentGain; + final double minSwipeThreshold; + final double maxSwipeThreshold; + + /// 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; @@ -62,11 +88,18 @@ class AppleTvRemoteTouchService { this.swipeThreshold = defaultSwipeThreshold, this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio, this.swipeRepeatInterval = defaultSwipeRepeatInterval, + this.swipeExtentGain = defaultSwipeExtentGain, + this.minSwipeThreshold = defaultMinSwipeThreshold, + this.maxSwipeThreshold = defaultMaxSwipeThreshold, + Rect? Function()? focusedItemRect, }) : assert(axisSwitchDominanceRatio >= 1), + assert(swipeExtentGain > 0), + assert(minSwipeThreshold > 0 && minSwipeThreshold <= maxSwipeThreshold), _channel = channel ?? const BasicMessageChannel(_channelName, JSONMessageCodec()), _simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress, _scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle, _now = now ?? DateTime.now, + _focusedItemRect = focusedItemRect ?? _defaultFocusedItemRect, _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow); @@ -192,52 +225,92 @@ 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 ? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight) : (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown); - _emitKey(logicalKey, source: 'swipe', detail: 'dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}'); + _emitKey( + logicalKey, + source: 'swipe', + detail: + 'dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)} ' + 'thX=${_formatDouble(thresholds.horizontal)} thY=${_formatDouble(thresholds.vertical)}', + ); _anchorX = x; _anchorY = y; _lastSwipeAxis = axis; _lastSwipeAt = now; } + /// Resolves which axis, if any, crossed its step threshold. + /// + /// 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 the item's 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 absX = deltaX.abs(); - final absY = deltaY.abs(); - if (absX < swipeThreshold && absY < swipeThreshold) return null; + final progressX = deltaX.abs() / thresholds.horizontal; + final progressY = deltaY.abs() / thresholds.vertical; + if (progressX < 1 && progressY < 1) return null; - final candidate = absX >= absY ? _SwipeAxis.horizontal : _SwipeAxis.vertical; + final candidate = progressX >= progressY ? _SwipeAxis.horizontal : _SwipeAxis.vertical; final lastAxis = _lastSwipeAxis; if (lastAxis == null || candidate == lastAxis) return candidate; - final totalX = (_startX - x).abs(); - final totalY = (_startY - y).abs(); - final candidateTotal = _axisDistance(candidate, totalX, totalY); - final lastAxisTotal = _axisDistance(lastAxis, totalX, totalY); - final candidateSegment = _axisDistance(candidate, absX, absY); - final lastAxisSegment = _axisDistance(lastAxis, absX, absY); + 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); + final lastAxisSegment = _axisValue(lastAxis, progressX, progressY); if (candidateTotal >= lastAxisTotal * axisSwitchDominanceRatio && candidateSegment >= lastAxisSegment * axisSwitchDominanceRatio) { return candidate; } - return lastAxisSegment >= swipeThreshold ? lastAxis : null; + return lastAxisSegment >= 1 ? lastAxis : null; } - double _axisDistance(_SwipeAxis axis, double horizontal, double vertical) { + double _axisValue(_SwipeAxis axis, double horizontal, double vertical) { 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 * swipeExtentGain).clamp(minSwipeThreshold, maxSwipeThreshold).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. + static Rect? _defaultFocusedItemRect() { + final node = FocusManager.instance.primaryFocus; + if (node == null || node is FocusScopeNode || node is DirectionalShortcutFocusNode) return null; + final context = node.context; + if (context == null) return null; + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.attached || !renderObject.hasSize) return null; + final rect = node.rect; + if (!rect.isFinite || rect.isEmpty) return null; + return rect; + } + bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) { if (_duplicateInputGuard.shouldSuppressSyntheticKey(logicalKey)) { _log('suppress key=${_keyName(logicalKey)} source=$source reason=recent-native'); diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index e326154e..96e2d66b 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -188,18 +188,99 @@ void main() { expect(harness.keys, isEmpty); }); + + test('a small focused item lowers the swipe distance for a step', () async { + final harness = _Harness(minSwipeThreshold: 40); + harness.focusRect = const Rect.fromLTWH(0, 0, 60, 60); + + // 60pt extent × 0.55 gain = 33pt, min-clamped to the 40pt floor: fires + // well below the 100pt fallback threshold. + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 430, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + }); + + test('a large focused item demands a longer swipe for a step', () async { + final harness = _Harness(); + harness.focusRect = const Rect.fromLTWH(0, 0, 300, 300); + + // 300pt extent × 0.55 gain = 165pt step: the fallback-sized 120pt move + // that used to fire must not. + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 380, y: 500); + + expect(harness.keys, isEmpty); + + await harness.send('move', x: 160, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + }); + + test('the swipe distance is capped for oversized focused items', () async { + final harness = _Harness(); + harness.focusRect = const Rect.fromLTWH(0, 0, 3000, 3000); + + await harness.send('started', x: 900, y: 500); + await harness.send('move', x: 530, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + }); + + test('per-axis thresholds follow the focused item shape', () async { + final harness = _Harness(); + harness.focusRect = const Rect.fromLTWH(0, 0, 300, 100); + + // Raw horizontal delta (200pt) dominates vertical (130pt), but only the + // vertical axis crossed its threshold (55pt vs 165pt): the step must + // go down, like dragging focus off a wide-flat tile natively. + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 300, y: 630); + + expect(harness.keys, [LogicalKeyboardKey.arrowDown]); + }); + + test('a focus hop re-prices the next step from the new item', () async { + final harness = _Harness(minSwipeThreshold: 40); + harness.focusRect = const Rect.fromLTWH(0, 0, 60, 60); + + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 430, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + + // Focus landed on a large card: the next step costs its extent, not the + // small chip's. + harness.focusRect = const Rect.fromLTWH(0, 0, 300, 300); + harness.advance(const Duration(milliseconds: 141)); + await harness.send('move', x: 310, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + + await harness.send('move', x: 95, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]); + }); }); } class _Harness { + _Harness({this.minSwipeThreshold = AppleTvRemoteTouchService.defaultMinSwipeThreshold}); + DateTime now = DateTime(2026, 5, 5, 12); final List keys = []; + final double minSwipeThreshold; + + /// Fake focus geometry; null exercises the fixed-threshold fallback. + Rect? focusRect; late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService( simulateKeyPress: keys.add, scheduleFrame: () {}, now: () => now, swipeThreshold: 100, + minSwipeThreshold: minSwipeThreshold, + focusedItemRect: () => focusRect, ); Future send(String type, {double x = 0, double y = 0}) {