From 91298294b00aec1526b20f64346504a6acecbf2b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 15 May 2026 22:16:24 +0200 Subject: [PATCH] fix(tvos): stabilize remote select handling --- .../apple_tv_remote_touch_service.dart | 42 ++++++++++-- lib/utils/key_event_simulator.dart | 67 ++++++++++++++----- .../apple_tv_remote_touch_service_test.dart | 33 +++++++-- test/utils/key_event_simulator_test.dart | 65 +++++++++++------- tvos/engine.version | 2 +- 5 files changed, 155 insertions(+), 54 deletions(-) diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 20e9ecab..38a019a0 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -20,6 +20,8 @@ class AppleTvRemoteTouchService { final BasicMessageChannel _channel; final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress; + final void Function(LogicalKeyboardKey logicalKey) _simulateKeyDown; + final void Function(LogicalKeyboardKey logicalKey) _simulateKeyUp; final VoidCallback _scheduleFrame; final DateTime Function() _now; final GamepadDuplicateInputGuard _duplicateInputGuard; @@ -39,10 +41,13 @@ class AppleTvRemoteTouchService { DateTime? _lastSwipeAt; DateTime? _lastDirectionalInputAt; DateTime? _lastSyntheticSelectAt; + bool _selectPressedFromClick = false; AppleTvRemoteTouchService({ BasicMessageChannel? channel, void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress, + void Function(LogicalKeyboardKey logicalKey)? simulateKeyDown, + void Function(LogicalKeyboardKey logicalKey)? simulateKeyUp, VoidCallback? scheduleFrame, DateTime Function()? now, GamepadDuplicateInputGuard? duplicateInputGuard, @@ -54,6 +59,8 @@ class AppleTvRemoteTouchService { }) : assert(axisSwitchDominanceRatio >= 1), _channel = channel ?? const BasicMessageChannel(_channelName, JSONMessageCodec()), _simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress, + _simulateKeyDown = simulateKeyDown ?? key_sim.simulateKeyDown, + _simulateKeyUp = simulateKeyUp ?? key_sim.simulateKeyUp, _scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle, _now = now ?? DateTime.now, _duplicateInputGuard = @@ -72,6 +79,7 @@ class AppleTvRemoteTouchService { _channel.setMessageHandler(null); _unregisterNativeKeyHandler(); _duplicateInputGuard.clear(); + _releaseSelectFromClick(source: 'stop'); _resetTouch(); _listening = false; } @@ -117,8 +125,9 @@ class AppleTvRemoteTouchService { case 'cancelled': _resetTouch(); case 'click_e': - _emitSelect(); + _releaseSelectFromClick(source: 'click_e'); case 'click_s': + _pressSelectFromClick(); case 'loc': break; default: @@ -212,12 +221,12 @@ class AppleTvRemoteTouchService { return axis == _SwipeAxis.horizontal ? horizontal : vertical; } - void _emitSelect() { + void _pressSelectFromClick() { final now = _now(); final lastDirectionalInputAt = _lastDirectionalInputAt; if (lastDirectionalInputAt != null && now.difference(lastDirectionalInputAt) <= clickAfterDirectionSuppression) { 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; } @@ -225,14 +234,35 @@ class AppleTvRemoteTouchService { if (lastSyntheticSelectAt != null && now.difference(lastSyntheticSelectAt).abs() <= duplicateSuppressionWindow) { final age = now.difference(lastSyntheticSelectAt).abs().inMilliseconds; _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; } - if (_emitKey(LogicalKeyboardKey.enter, source: 'click_e')) { - _lastSyntheticSelectAt = now; + if (_duplicateInputGuard.shouldSuppressSyntheticKey(LogicalKeyboardKey.enter)) { + _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}) { diff --git a/lib/utils/key_event_simulator.dart b/lib/utils/key_event_simulator.dart index b23d3bcb..88665d68 100644 --- a/lib/utils/key_event_simulator.dart +++ b/lib/utils/key_event_simulator.dart @@ -30,15 +30,7 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) { deviceType: ui.KeyEventDeviceType.directionalPad, ); - FocusNode? node = focusNode; - KeyEventResult result = KeyEventResult.ignored; - - while (node != null && result != KeyEventResult.handled) { - if (node.onKeyEvent != null) { - result = node.onKeyEvent!(node, keyDownEvent); - } - node = node.parent; - } + _dispatchKeyEvent(focusNode, keyDownEvent); final keyUpEvent = KeyUpEvent( physicalKey: physicalKey, @@ -47,17 +39,58 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) { deviceType: ui.KeyEventDeviceType.directionalPad, ); - node = focusNode; - while (node != null) { - if (node.onKeyEvent != null) { - final upResult = node.onKeyEvent!(node, keyUpEvent); - if (upResult == KeyEventResult.handled) break; - } - node = node.parent; - } + _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) { + if (node.onKeyEvent != null && node.onKeyEvent!(node, event) == KeyEventResult.handled) { + break; + } + node = node.parent; + } +} + /// Force a frame when the engine is idle so focus visuals update immediately /// on external input (desktop may not wake up without mouse/keyboard activity). void scheduleFrameIfIdle() { diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index 7fa3b544..b8f61701 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -134,48 +134,60 @@ void main() { 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(); await harness.send('started', x: 500, y: 500); await harness.send('ended', x: 500, y: 500); + await harness.send('click_s'); 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)); + await harness.send('click_s'); 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 { final harness = _Harness(); harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)); + await harness.send('click_s'); await harness.send('click_e'); - expect(harness.keys, isEmpty); + expect(harness.keyDowns, isEmpty); + expect(harness.keyUps, isEmpty); harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)); harness.advance(const Duration(milliseconds: 121)); + await harness.send('click_s'); 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 { final harness = _Harness(); harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft)); + await harness.send('click_s'); await harness.send('click_e'); - expect(harness.keys, isEmpty); + expect(harness.keyDowns, isEmpty); + expect(harness.keyUps, isEmpty); harness.advance(const Duration(milliseconds: 221)); + await harness.send('click_s'); 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 { @@ -183,9 +195,12 @@ void main() { await harness.send('started', x: 500, y: 500); await harness.send('move', x: 380, y: 500); + await harness.send('click_s'); await harness.send('click_e'); 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 { @@ -204,9 +219,13 @@ void main() { class _Harness { DateTime now = DateTime(2026, 5, 5, 12); final List keys = []; + final List keyDowns = []; + final List keyUps = []; late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService( simulateKeyPress: keys.add, + simulateKeyDown: keyDowns.add, + simulateKeyUp: keyUps.add, scheduleFrame: () {}, now: () => now, swipeThreshold: 100, diff --git a/test/utils/key_event_simulator_test.dart b/test/utils/key_event_simulator_test.dart index 581bbcaa..1bf56448 100644 --- a/test/utils/key_event_simulator_test.dart +++ b/test/utils/key_event_simulator_test.dart @@ -7,29 +7,7 @@ import 'package:plezy/utils/key_event_simulator.dart'; void main() { testWidgets('simulateKeyPress dispatches directional pad key events', (tester) async { - final events = []; - late BuildContext focusContext; - - await tester.pumpWidget( - MaterialApp( - home: Focus( - autofocus: true, - onKeyEvent: (_, event) { - events.add(event); - return KeyEventResult.handled; - }, - child: Builder( - builder: (context) { - focusContext = context; - return const SizedBox.shrink(); - }, - ), - ), - ), - ); - Focus.of(focusContext).requestFocus(); - await tester.pump(); - expect(Focus.of(focusContext).hasPrimaryFocus, isTrue); + final events = await _pumpKeyEventRecorder(tester); scheduleFrameIfIdle(); simulateKeyPress(LogicalKeyboardKey.enter); @@ -39,4 +17,45 @@ void main() { 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()); + expect(events[1], isA()); + expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad)); + }); +} + +Future> _pumpKeyEventRecorder(WidgetTester tester) async { + final events = []; + late BuildContext focusContext; + + await tester.pumpWidget( + MaterialApp( + home: Focus( + autofocus: true, + onKeyEvent: (_, event) { + events.add(event); + return KeyEventResult.handled; + }, + child: Builder( + builder: (context) { + focusContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + Focus.of(focusContext).requestFocus(); + await tester.pump(); + expect(Focus.of(focusContext).hasPrimaryFocus, isTrue); + return events; } diff --git a/tvos/engine.version b/tvos/engine.version index 96b5835b..68aa6ec1 100644 --- a/tvos/engine.version +++ b/tvos/engine.version @@ -1 +1 @@ -3.41.6+9 +3.41.6+10