fix(tvos): stabilize remote select handling
This commit is contained in:
@@ -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}) {
|
||||||
|
|||||||
@@ -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,17 +39,58 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
|||||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
deviceType: ui.KeyEventDeviceType.directionalPad,
|
||||||
);
|
);
|
||||||
|
|
||||||
node = focusNode;
|
_dispatchKeyEvent(focusNode, keyUpEvent);
|
||||||
while (node != null) {
|
|
||||||
if (node.onKeyEvent != null) {
|
|
||||||
final upResult = node.onKeyEvent!(node, keyUpEvent);
|
|
||||||
if (upResult == KeyEventResult.handled) break;
|
|
||||||
}
|
|
||||||
node = node.parent;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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).
|
/// on external input (desktop may not wake up without mouse/keyboard activity).
|
||||||
void scheduleFrameIfIdle() {
|
void scheduleFrameIfIdle() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -7,29 +7,7 @@ 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 = <KeyEvent>[];
|
final events = await _pumpKeyEventRecorder(tester);
|
||||||
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);
|
|
||||||
|
|
||||||
scheduleFrameIfIdle();
|
scheduleFrameIfIdle();
|
||||||
simulateKeyPress(LogicalKeyboardKey.enter);
|
simulateKeyPress(LogicalKeyboardKey.enter);
|
||||||
@@ -39,4 +17,45 @@ void main() {
|
|||||||
expect(events, hasLength(2));
|
expect(events, hasLength(2));
|
||||||
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
|
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>[];
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.41.6+9
|
3.41.6+10
|
||||||
|
|||||||
Reference in New Issue
Block a user