fix(tvos): stabilize remote select handling

This commit is contained in:
edde746
2026-05-15 22:16:24 +02:00
parent 5df3ec0c12
commit 91298294b0
5 changed files with 155 additions and 54 deletions
@@ -20,6 +20,8 @@ class AppleTvRemoteTouchService {
final BasicMessageChannel<dynamic> _channel; final BasicMessageChannel<dynamic> _channel;
final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress; final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress;
final void Function(LogicalKeyboardKey logicalKey) _simulateKeyDown;
final void Function(LogicalKeyboardKey logicalKey) _simulateKeyUp;
final VoidCallback _scheduleFrame; final VoidCallback _scheduleFrame;
final DateTime Function() _now; final DateTime Function() _now;
final GamepadDuplicateInputGuard _duplicateInputGuard; final GamepadDuplicateInputGuard _duplicateInputGuard;
@@ -39,10 +41,13 @@ class AppleTvRemoteTouchService {
DateTime? _lastSwipeAt; DateTime? _lastSwipeAt;
DateTime? _lastDirectionalInputAt; DateTime? _lastDirectionalInputAt;
DateTime? _lastSyntheticSelectAt; DateTime? _lastSyntheticSelectAt;
bool _selectPressedFromClick = false;
AppleTvRemoteTouchService({ AppleTvRemoteTouchService({
BasicMessageChannel<dynamic>? channel, BasicMessageChannel<dynamic>? channel,
void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress, void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress,
void Function(LogicalKeyboardKey logicalKey)? simulateKeyDown,
void Function(LogicalKeyboardKey logicalKey)? simulateKeyUp,
VoidCallback? scheduleFrame, VoidCallback? scheduleFrame,
DateTime Function()? now, DateTime Function()? now,
GamepadDuplicateInputGuard? duplicateInputGuard, GamepadDuplicateInputGuard? duplicateInputGuard,
@@ -54,6 +59,8 @@ class AppleTvRemoteTouchService {
}) : assert(axisSwitchDominanceRatio >= 1), }) : assert(axisSwitchDominanceRatio >= 1),
_channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()), _channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
_simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress, _simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
_simulateKeyDown = simulateKeyDown ?? key_sim.simulateKeyDown,
_simulateKeyUp = simulateKeyUp ?? key_sim.simulateKeyUp,
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle, _scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
_now = now ?? DateTime.now, _now = now ?? DateTime.now,
_duplicateInputGuard = _duplicateInputGuard =
@@ -72,6 +79,7 @@ class AppleTvRemoteTouchService {
_channel.setMessageHandler(null); _channel.setMessageHandler(null);
_unregisterNativeKeyHandler(); _unregisterNativeKeyHandler();
_duplicateInputGuard.clear(); _duplicateInputGuard.clear();
_releaseSelectFromClick(source: 'stop');
_resetTouch(); _resetTouch();
_listening = false; _listening = false;
} }
@@ -117,8 +125,9 @@ class AppleTvRemoteTouchService {
case 'cancelled': case 'cancelled':
_resetTouch(); _resetTouch();
case 'click_e': case 'click_e':
_emitSelect(); _releaseSelectFromClick(source: 'click_e');
case 'click_s': case 'click_s':
_pressSelectFromClick();
case 'loc': case 'loc':
break; break;
default: default:
@@ -212,12 +221,12 @@ class AppleTvRemoteTouchService {
return axis == _SwipeAxis.horizontal ? horizontal : vertical; return axis == _SwipeAxis.horizontal ? horizontal : vertical;
} }
void _emitSelect() { void _pressSelectFromClick() {
final now = _now(); final now = _now();
final lastDirectionalInputAt = _lastDirectionalInputAt; final lastDirectionalInputAt = _lastDirectionalInputAt;
if (lastDirectionalInputAt != null && now.difference(lastDirectionalInputAt) <= clickAfterDirectionSuppression) { if (lastDirectionalInputAt != null && now.difference(lastDirectionalInputAt) <= clickAfterDirectionSuppression) {
final age = now.difference(lastDirectionalInputAt).inMilliseconds; final age = now.difference(lastDirectionalInputAt).inMilliseconds;
_log('suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_e reason=recent-direction age=${age}ms'); _log('suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-direction age=${age}ms');
return; return;
} }
@@ -225,14 +234,35 @@ class AppleTvRemoteTouchService {
if (lastSyntheticSelectAt != null && now.difference(lastSyntheticSelectAt).abs() <= duplicateSuppressionWindow) { if (lastSyntheticSelectAt != null && now.difference(lastSyntheticSelectAt).abs() <= duplicateSuppressionWindow) {
final age = now.difference(lastSyntheticSelectAt).abs().inMilliseconds; final age = now.difference(lastSyntheticSelectAt).abs().inMilliseconds;
_log( _log(
'suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_e reason=recent-synthetic-select age=${age}ms', 'suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-synthetic-select age=${age}ms',
); );
return; return;
} }
if (_emitKey(LogicalKeyboardKey.enter, source: 'click_e')) { if (_duplicateInputGuard.shouldSuppressSyntheticKey(LogicalKeyboardKey.enter)) {
_lastSyntheticSelectAt = now; _log('suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-native');
return;
} }
_setTraditionalFocusHighlight();
_scheduleFrame();
_selectPressedFromClick = true;
_log('emit keydown=${_keyName(LogicalKeyboardKey.enter)} source=click_s');
_simulateKeyDown(LogicalKeyboardKey.enter);
}
void _releaseSelectFromClick({required String source}) {
if (!_selectPressedFromClick) {
_log('ignore keyup=${_keyName(LogicalKeyboardKey.enter)} source=$source reason=no-click-select-down');
return;
}
_setTraditionalFocusHighlight();
_scheduleFrame();
_selectPressedFromClick = false;
_lastSyntheticSelectAt = _now();
_log('emit keyup=${_keyName(LogicalKeyboardKey.enter)} source=$source');
_simulateKeyUp(LogicalKeyboardKey.enter);
} }
bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) { bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) {
+47 -14
View File
@@ -30,15 +30,7 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) {
deviceType: ui.KeyEventDeviceType.directionalPad, deviceType: ui.KeyEventDeviceType.directionalPad,
); );
FocusNode? node = focusNode; _dispatchKeyEvent(focusNode, keyDownEvent);
KeyEventResult result = KeyEventResult.ignored;
while (node != null && result != KeyEventResult.handled) {
if (node.onKeyEvent != null) {
result = node.onKeyEvent!(node, keyDownEvent);
}
node = node.parent;
}
final keyUpEvent = KeyUpEvent( final keyUpEvent = KeyUpEvent(
physicalKey: physicalKey, physicalKey: physicalKey,
@@ -47,15 +39,56 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) {
deviceType: ui.KeyEventDeviceType.directionalPad, deviceType: ui.KeyEventDeviceType.directionalPad,
); );
node = focusNode; _dispatchKeyEvent(focusNode, keyUpEvent);
});
}
/// Simulate only key down. Pair with [simulateKeyUp] for held buttons.
void simulateKeyDown(LogicalKeyboardKey logicalKey) {
scheduleFrameIfIdle();
SchedulerBinding.instance.addPostFrameCallback((_) {
final focusNode = FocusManager.instance.primaryFocus;
if (focusNode == null) return;
_dispatchKeyEvent(
focusNode,
KeyDownEvent(
physicalKey: _getPhysicalKey(logicalKey),
logicalKey: logicalKey,
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
deviceType: ui.KeyEventDeviceType.directionalPad,
),
);
});
}
/// Simulate only key up. The release half of [simulateKeyDown].
void simulateKeyUp(LogicalKeyboardKey logicalKey) {
scheduleFrameIfIdle();
SchedulerBinding.instance.addPostFrameCallback((_) {
final focusNode = FocusManager.instance.primaryFocus;
if (focusNode == null) return;
_dispatchKeyEvent(
focusNode,
KeyUpEvent(
physicalKey: _getPhysicalKey(logicalKey),
logicalKey: logicalKey,
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
deviceType: ui.KeyEventDeviceType.directionalPad,
),
);
});
}
void _dispatchKeyEvent(FocusNode focusNode, KeyEvent event) {
FocusNode? node = focusNode;
while (node != null) { while (node != null) {
if (node.onKeyEvent != null) { if (node.onKeyEvent != null && node.onKeyEvent!(node, event) == KeyEventResult.handled) {
final upResult = node.onKeyEvent!(node, keyUpEvent); break;
if (upResult == KeyEventResult.handled) break;
} }
node = node.parent; node = node.parent;
} }
});
} }
/// Force a frame when the engine is idle so focus visuals update immediately /// Force a frame when the engine is idle so focus visuals update immediately
@@ -134,48 +134,60 @@ void main() {
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
}); });
test('deduplicates touch tap and click fallback select events', () async { test('click events emit held select key down and up', () async {
final harness = _Harness(); final harness = _Harness();
await harness.send('started', x: 500, y: 500); await harness.send('started', x: 500, y: 500);
await harness.send('ended', x: 500, y: 500); await harness.send('ended', x: 500, y: 500);
await harness.send('click_s');
await harness.send('click_e'); await harness.send('click_e');
expect(harness.keys, [LogicalKeyboardKey.enter]); expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
harness.advance(const Duration(milliseconds: 121)); harness.advance(const Duration(milliseconds: 121));
await harness.send('click_s');
await harness.send('click_e'); await harness.send('click_e');
expect(harness.keys, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]); expect(harness.keyDowns, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]);
expect(harness.keyUps, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]);
}); });
test('native select suppresses click fallback from physical remote path', () async { test('native select suppresses click fallback from physical remote path', () async {
final harness = _Harness(); final harness = _Harness();
harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)); harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select));
await harness.send('click_s');
await harness.send('click_e'); await harness.send('click_e');
expect(harness.keys, isEmpty); expect(harness.keyDowns, isEmpty);
expect(harness.keyUps, isEmpty);
harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)); harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select));
harness.advance(const Duration(milliseconds: 121)); harness.advance(const Duration(milliseconds: 121));
await harness.send('click_s');
await harness.send('click_e'); await harness.send('click_e');
expect(harness.keys, [LogicalKeyboardKey.enter]); expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
}); });
test('recent directional input suppresses click fallback', () async { test('recent directional input suppresses click fallback', () async {
final harness = _Harness(); final harness = _Harness();
harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft)); harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft));
await harness.send('click_s');
await harness.send('click_e'); await harness.send('click_e');
expect(harness.keys, isEmpty); expect(harness.keyDowns, isEmpty);
expect(harness.keyUps, isEmpty);
harness.advance(const Duration(milliseconds: 221)); harness.advance(const Duration(milliseconds: 221));
await harness.send('click_s');
await harness.send('click_e'); await harness.send('click_e');
expect(harness.keys, [LogicalKeyboardKey.enter]); expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
}); });
test('synthetic swipe suppresses click fallback', () async { test('synthetic swipe suppresses click fallback', () async {
@@ -183,9 +195,12 @@ void main() {
await harness.send('started', x: 500, y: 500); await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 380, y: 500); await harness.send('move', x: 380, y: 500);
await harness.send('click_s');
await harness.send('click_e'); await harness.send('click_e');
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
expect(harness.keyDowns, isEmpty);
expect(harness.keyUps, isEmpty);
}); });
test('cancelled touch does not emit select on a later ended message', () async { test('cancelled touch does not emit select on a later ended message', () async {
@@ -204,9 +219,13 @@ void main() {
class _Harness { class _Harness {
DateTime now = DateTime(2026, 5, 5, 12); DateTime now = DateTime(2026, 5, 5, 12);
final List<LogicalKeyboardKey> keys = []; final List<LogicalKeyboardKey> keys = [];
final List<LogicalKeyboardKey> keyDowns = [];
final List<LogicalKeyboardKey> keyUps = [];
late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService( late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService(
simulateKeyPress: keys.add, simulateKeyPress: keys.add,
simulateKeyDown: keyDowns.add,
simulateKeyUp: keyUps.add,
scheduleFrame: () {}, scheduleFrame: () {},
now: () => now, now: () => now,
swipeThreshold: 100, swipeThreshold: 100,
+28 -9
View File
@@ -7,6 +7,33 @@ import 'package:plezy/utils/key_event_simulator.dart';
void main() { void main() {
testWidgets('simulateKeyPress dispatches directional pad key events', (tester) async { testWidgets('simulateKeyPress dispatches directional pad key events', (tester) async {
final events = await _pumpKeyEventRecorder(tester);
scheduleFrameIfIdle();
simulateKeyPress(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump();
expect(events, hasLength(2));
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
});
testWidgets('simulateKeyDown and simulateKeyUp dispatch held directional pad events', (tester) async {
final events = await _pumpKeyEventRecorder(tester);
simulateKeyDown(LogicalKeyboardKey.enter);
simulateKeyUp(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump();
expect(events, hasLength(2));
expect(events[0], isA<KeyDownEvent>());
expect(events[1], isA<KeyUpEvent>());
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
});
}
Future<List<KeyEvent>> _pumpKeyEventRecorder(WidgetTester tester) async {
final events = <KeyEvent>[]; final events = <KeyEvent>[];
late BuildContext focusContext; late BuildContext focusContext;
@@ -30,13 +57,5 @@ void main() {
Focus.of(focusContext).requestFocus(); Focus.of(focusContext).requestFocus();
await tester.pump(); await tester.pump();
expect(Focus.of(focusContext).hasPrimaryFocus, isTrue); expect(Focus.of(focusContext).hasPrimaryFocus, isTrue);
return events;
scheduleFrameIfIdle();
simulateKeyPress(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump();
expect(events, hasLength(2));
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
});
} }
+1 -1
View File
@@ -1 +1 @@
3.41.6+9 3.41.6+10