feat(tvos): scale Siri Remote swipe distance to the focused item
A focus step cost a fixed 180pt of pan travel regardless of what was focused, so small controls felt sluggish and large cards hair-triggered compared with the native focus engine, which prices a step by on-screen geometry. Derive per-axis thresholds from the focused control's rect (gain 1.1, clamped 100-360pt) and normalize axis resolution by them, so a wide-flat tile steps vertically once the finger covers its height. Focus scopes, the player's screen-sized catch-all surfaces, and nodes without layout fall back to the fixed threshold, keeping player chrome behavior unchanged.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../focus/focus_navigation_intent.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../focus/input_mode_tracker.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/key_event_simulator.dart' as key_sim;
|
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
|
/// Bridges tvOS touch-surface events from Apple's iOS Remote app into the
|
||||||
/// focus-tree key events Plezy already handles for D-pad navigation.
|
/// 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 {
|
class AppleTvRemoteTouchService {
|
||||||
static const String _channelName = 'flutter/gamepadtouchevent';
|
static const String _channelName = 'flutter/gamepadtouchevent';
|
||||||
static const double defaultSwipeThreshold = 180;
|
static const double defaultSwipeThreshold = 180;
|
||||||
static const double defaultAxisSwitchDominanceRatio = 1.5;
|
static const double defaultAxisSwitchDominanceRatio = 1.5;
|
||||||
static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140);
|
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();
|
static final AppleTvRemoteTouchService instance = AppleTvRemoteTouchService();
|
||||||
|
|
||||||
@@ -37,10 +51,22 @@ class AppleTvRemoteTouchService {
|
|||||||
final void Function() reportNonPointerInput;
|
final void Function() reportNonPointerInput;
|
||||||
final StreamController<AppleTvRemotePlayPauseAction> _playPauseController =
|
final StreamController<AppleTvRemotePlayPauseAction> _playPauseController =
|
||||||
StreamController<AppleTvRemotePlayPauseAction>.broadcast();
|
StreamController<AppleTvRemotePlayPauseAction>.broadcast();
|
||||||
|
|
||||||
|
/// Fallback step distance when no usable focus geometry exists.
|
||||||
final double swipeThreshold;
|
final double swipeThreshold;
|
||||||
final double axisSwitchDominanceRatio;
|
final double axisSwitchDominanceRatio;
|
||||||
final Duration swipeRepeatInterval;
|
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 _listening = false;
|
||||||
bool _nativeKeyHandlerRegistered = false;
|
bool _nativeKeyHandlerRegistered = false;
|
||||||
bool _touchActive = false;
|
bool _touchActive = false;
|
||||||
@@ -62,11 +88,18 @@ class AppleTvRemoteTouchService {
|
|||||||
this.swipeThreshold = defaultSwipeThreshold,
|
this.swipeThreshold = defaultSwipeThreshold,
|
||||||
this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio,
|
this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio,
|
||||||
this.swipeRepeatInterval = defaultSwipeRepeatInterval,
|
this.swipeRepeatInterval = defaultSwipeRepeatInterval,
|
||||||
|
this.swipeExtentGain = defaultSwipeExtentGain,
|
||||||
|
this.minSwipeThreshold = defaultMinSwipeThreshold,
|
||||||
|
this.maxSwipeThreshold = defaultMaxSwipeThreshold,
|
||||||
|
Rect? Function()? focusedItemRect,
|
||||||
}) : assert(axisSwitchDominanceRatio >= 1),
|
}) : assert(axisSwitchDominanceRatio >= 1),
|
||||||
|
assert(swipeExtentGain > 0),
|
||||||
|
assert(minSwipeThreshold > 0 && minSwipeThreshold <= maxSwipeThreshold),
|
||||||
_channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
|
_channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
|
||||||
_simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
|
_simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
|
||||||
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
|
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
|
||||||
_now = now ?? DateTime.now,
|
_now = now ?? DateTime.now,
|
||||||
|
_focusedItemRect = focusedItemRect ?? _defaultFocusedItemRect,
|
||||||
_duplicateInputGuard =
|
_duplicateInputGuard =
|
||||||
duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow);
|
duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow);
|
||||||
|
|
||||||
@@ -192,52 +225,92 @@ class AppleTvRemoteTouchService {
|
|||||||
return;
|
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;
|
if (axis == null) return;
|
||||||
|
|
||||||
final logicalKey = axis == _SwipeAxis.horizontal
|
final logicalKey = axis == _SwipeAxis.horizontal
|
||||||
? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight)
|
? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight)
|
||||||
: (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown);
|
: (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;
|
_anchorX = x;
|
||||||
_anchorY = y;
|
_anchorY = y;
|
||||||
_lastSwipeAxis = axis;
|
_lastSwipeAxis = axis;
|
||||||
_lastSwipeAt = now;
|
_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({
|
_SwipeAxis? _resolveSwipeAxis({
|
||||||
required double x,
|
required double x,
|
||||||
required double y,
|
required double y,
|
||||||
required double deltaX,
|
required double deltaX,
|
||||||
required double deltaY,
|
required double deltaY,
|
||||||
|
required ({double horizontal, double vertical}) thresholds,
|
||||||
}) {
|
}) {
|
||||||
final absX = deltaX.abs();
|
final progressX = deltaX.abs() / thresholds.horizontal;
|
||||||
final absY = deltaY.abs();
|
final progressY = deltaY.abs() / thresholds.vertical;
|
||||||
if (absX < swipeThreshold && absY < swipeThreshold) return null;
|
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;
|
final lastAxis = _lastSwipeAxis;
|
||||||
if (lastAxis == null || candidate == lastAxis) return candidate;
|
if (lastAxis == null || candidate == lastAxis) return candidate;
|
||||||
|
|
||||||
final totalX = (_startX - x).abs();
|
final totalProgressX = (_startX - x).abs() / thresholds.horizontal;
|
||||||
final totalY = (_startY - y).abs();
|
final totalProgressY = (_startY - y).abs() / thresholds.vertical;
|
||||||
final candidateTotal = _axisDistance(candidate, totalX, totalY);
|
final candidateTotal = _axisValue(candidate, totalProgressX, totalProgressY);
|
||||||
final lastAxisTotal = _axisDistance(lastAxis, totalX, totalY);
|
final lastAxisTotal = _axisValue(lastAxis, totalProgressX, totalProgressY);
|
||||||
final candidateSegment = _axisDistance(candidate, absX, absY);
|
final candidateSegment = _axisValue(candidate, progressX, progressY);
|
||||||
final lastAxisSegment = _axisDistance(lastAxis, absX, absY);
|
final lastAxisSegment = _axisValue(lastAxis, progressX, progressY);
|
||||||
if (candidateTotal >= lastAxisTotal * axisSwitchDominanceRatio &&
|
if (candidateTotal >= lastAxisTotal * axisSwitchDominanceRatio &&
|
||||||
candidateSegment >= lastAxisSegment * axisSwitchDominanceRatio) {
|
candidateSegment >= lastAxisSegment * axisSwitchDominanceRatio) {
|
||||||
return candidate;
|
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;
|
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}) {
|
bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) {
|
||||||
if (_duplicateInputGuard.shouldSuppressSyntheticKey(logicalKey)) {
|
if (_duplicateInputGuard.shouldSuppressSyntheticKey(logicalKey)) {
|
||||||
_log('suppress key=${_keyName(logicalKey)} source=$source reason=recent-native');
|
_log('suppress key=${_keyName(logicalKey)} source=$source reason=recent-native');
|
||||||
|
|||||||
@@ -188,18 +188,99 @@ void main() {
|
|||||||
|
|
||||||
expect(harness.keys, isEmpty);
|
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 {
|
class _Harness {
|
||||||
|
_Harness({this.minSwipeThreshold = AppleTvRemoteTouchService.defaultMinSwipeThreshold});
|
||||||
|
|
||||||
DateTime now = DateTime(2026, 5, 5, 12);
|
DateTime now = DateTime(2026, 5, 5, 12);
|
||||||
final List<LogicalKeyboardKey> keys = [];
|
final List<LogicalKeyboardKey> keys = [];
|
||||||
|
final double minSwipeThreshold;
|
||||||
|
|
||||||
|
/// Fake focus geometry; null exercises the fixed-threshold fallback.
|
||||||
|
Rect? focusRect;
|
||||||
|
|
||||||
late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService(
|
late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService(
|
||||||
simulateKeyPress: keys.add,
|
simulateKeyPress: keys.add,
|
||||||
scheduleFrame: () {},
|
scheduleFrame: () {},
|
||||||
now: () => now,
|
now: () => now,
|
||||||
swipeThreshold: 100,
|
swipeThreshold: 100,
|
||||||
|
minSwipeThreshold: minSwipeThreshold,
|
||||||
|
focusedItemRect: () => focusRect,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<void> send(String type, {double x = 0, double y = 0}) {
|
Future<void> send(String type, {double x = 0, double y = 0}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user