From 18a50413342e96f69d87ddbe9c43946bba2b60df Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 5 May 2026 23:49:31 +0200 Subject: [PATCH] fix(tvos): stabilize Siri Remote input --- lib/main.dart | 4 + lib/screens/profile/pin_entry_dialog.dart | 62 ++-- .../apple_tv_remote_touch_service.dart | 274 ++++++++++++++++++ lib/utils/key_event_simulator.dart | 5 +- pubspec.yaml | 2 +- .../apple_tv_remote_touch_service_test.dart | 155 ++++++++++ tvos/engine.version | 2 +- 7 files changed, 480 insertions(+), 24 deletions(-) create mode 100644 lib/services/apple_tv_remote_touch_service.dart create mode 100644 test/services/apple_tv_remote_touch_service_test.dart diff --git a/lib/main.dart b/lib/main.dart index 9ce43a59..e3941178 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,6 +31,7 @@ import 'services/native_window_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/settings_service.dart'; import 'utils/platform_detector.dart'; +import 'services/apple_tv_remote_touch_service.dart'; import 'services/discord_rpc_service.dart'; import 'services/gamepad_service.dart'; import 'services/trakt/trakt_scrobble_service.dart'; @@ -214,6 +215,9 @@ Future _bootstrapApp() async { // Initialize gamepad service (all platforms — universal_gamepad auto-registers // and intercepts input events, so we must listen to re-dispatch them) GamepadService.instance.start(); + if (PlatformDetector.isAppleTV()) { + AppleTvRemoteTouchService.instance.start(); + } if (PlatformDetector.isDesktopOS()) { unawaited(DiscordRPCService.instance.initialize()); diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index d8ea7d09..e1740d17 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -6,7 +6,6 @@ import '../../focus/dpad_navigator.dart'; import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; -import '../../focus/key_repeat_helper.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; @@ -28,6 +27,8 @@ class _PinEntryDialogState extends State with SingleTickerProvid late AnimationController _shakeController; late Animation _shakeAnimation; final _pinInputKey = GlobalKey<_TvPinInputState>(); + final _cancelFocusNode = FocusNode(debugLabel: 'PinCancelButton'); + final _submitFocusNode = FocusNode(debugLabel: 'PinSubmitButton'); @override void initState() { @@ -52,6 +53,8 @@ class _PinEntryDialogState extends State with SingleTickerProvid @override void dispose() { _shakeController.dispose(); + _cancelFocusNode.dispose(); + _submitFocusNode.dispose(); super.dispose(); } @@ -63,6 +66,18 @@ class _PinEntryDialogState extends State with SingleTickerProvid Navigator.of(context).pop(null); } + void _focusPinDigit(int index) { + _pinInputKey.currentState?._requestDigitFocus(index); + } + + void _focusSubmit() { + _submitFocusNode.requestFocus(); + } + + void _focusCancel() { + _cancelFocusNode.requestFocus(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -93,6 +108,7 @@ class _PinEntryDialogState extends State with SingleTickerProvid hasError: widget.errorMessage != null, isMobile: isMobile, isTV: isTV, + onMoveToSubmit: isMobile ? null : _focusSubmit, ), if (widget.errorMessage != null) ...[ const SizedBox(height: 12), @@ -102,12 +118,20 @@ class _PinEntryDialogState extends State with SingleTickerProvid ), actions: [ FocusableButton( + focusNode: _cancelFocusNode, onPressed: _cancel, + onNavigateRight: isMobile ? null : _focusSubmit, + onNavigateUp: () => _focusPinDigit(0), + onBack: _cancel, child: TextButton(onPressed: _cancel, child: Text(t.common.cancel)), ), if (!isMobile) FocusableButton( + focusNode: _submitFocusNode, onPressed: () => _pinInputKey.currentState?._trySubmit(), + onNavigateLeft: _focusCancel, + onNavigateUp: () => _focusPinDigit(3), + onBack: _cancel, child: FilledButton( onPressed: () => _pinInputKey.currentState?._trySubmit(), child: Text(t.common.submit), @@ -125,6 +149,7 @@ class _TvPinInput extends StatefulWidget { final bool hasError; final bool isMobile; final bool isTV; + final VoidCallback? onMoveToSubmit; const _TvPinInput({ super.key, @@ -133,13 +158,14 @@ class _TvPinInput extends StatefulWidget { required this.hasError, required this.isMobile, required this.isTV, + this.onMoveToSubmit, }); @override State<_TvPinInput> createState() => _TvPinInputState(); } -class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInput>, ControllerDisposerMixin { +class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { final List _digits = [null, null, null, null]; int _activeIndex = 0; bool _isFocused = false; @@ -168,7 +194,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp @override void dispose() { - stopRepeat(); _focusNode.dispose(); for (final node in _mobileFocusNodes) { node.dispose(); @@ -199,6 +224,12 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp if (pin != null) widget.onSubmit(pin); } + void _requestDigitFocus(int index) { + final nextIndex = index < 0 ? 0 : (index > 3 ? 3 : index); + setState(() => _activeIndex = nextIndex); + _focusNode.requestFocus(); + } + void _incrementDigit() { setState(() { _digits[_activeIndex] = ((_digits[_activeIndex] ?? -1) + 1) % 10; @@ -243,6 +274,9 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp final backResult = handleBackKeyAction(event, widget.onCancel); if (backResult != KeyEventResult.ignored) return backResult; + final selectResult = handleOneShotSelect(event, _trySubmit); + if (selectResult != KeyEventResult.ignored) return selectResult; + if (event is KeyDownEvent) { // Number key input (desktop only, TV uses d-pad) if (!widget.isTV) { @@ -268,16 +302,18 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp }); return KeyEventResult.handled; } + } + if (event.isActionable) { // Up arrow → increment digit if (key.isUpKey) { - startRepeat(_incrementDigit); + _incrementDigit(); return KeyEventResult.handled; } // Down arrow → decrement digit if (key.isDownKey) { - startRepeat(_decrementDigit); + _decrementDigit(); return KeyEventResult.handled; } @@ -295,20 +331,7 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp setState(() => _activeIndex++); return KeyEventResult.handled; } - // At rightmost digit, let focus move to submit button - return KeyEventResult.ignored; - } - - // Select / Enter → submit - if (key.isSelectKey) { - _trySubmit(); - return KeyEventResult.handled; - } - } - - if (event is KeyUpEvent) { - if (key.isUpKey || key.isDownKey) { - stopRepeat(); + widget.onMoveToSubmit?.call(); return KeyEventResult.handled; } } @@ -367,7 +390,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp autofocus: true, onFocusChange: (hasFocus) { setState(() => _isFocused = hasFocus); - if (!hasFocus) stopRepeat(); }, onKeyEvent: _handleKeyEvent, child: _buildDigitRow(context, showArrows: showArrows), diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart new file mode 100644 index 00000000..87bba8d9 --- /dev/null +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -0,0 +1,274 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../utils/app_logger.dart'; +import '../utils/key_event_simulator.dart' as key_sim; +import 'gamepad_service.dart'; + +/// Bridges tvOS touch-surface events from Apple's iOS Remote app into the +/// focus-tree key events Plezy already handles for D-pad navigation. +class AppleTvRemoteTouchService { + static const String _channelName = 'flutter/gamepadtouchevent'; + static const double defaultSwipeThreshold = 180; + static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140); + static const Duration defaultClickAfterDirectionSuppression = Duration(milliseconds: 220); + + static final AppleTvRemoteTouchService instance = AppleTvRemoteTouchService(); + + final BasicMessageChannel _channel; + final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress; + final VoidCallback _scheduleFrame; + final DateTime Function() _now; + final GamepadDuplicateInputGuard _duplicateInputGuard; + final double swipeThreshold; + final Duration swipeRepeatInterval; + final Duration clickAfterDirectionSuppression; + + bool _listening = false; + bool _nativeKeyHandlerRegistered = false; + bool _touchActive = false; + double _anchorX = 0; + double _anchorY = 0; + DateTime? _lastSwipeAt; + DateTime? _lastDirectionalInputAt; + DateTime? _lastSyntheticSelectAt; + + AppleTvRemoteTouchService({ + BasicMessageChannel? channel, + void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress, + VoidCallback? scheduleFrame, + DateTime Function()? now, + GamepadDuplicateInputGuard? duplicateInputGuard, + Duration duplicateSuppressionWindow = GamepadDuplicateInputGuard.defaultSuppressionWindow, + this.swipeThreshold = defaultSwipeThreshold, + this.swipeRepeatInterval = defaultSwipeRepeatInterval, + this.clickAfterDirectionSuppression = defaultClickAfterDirectionSuppression, + }) : _channel = channel ?? const BasicMessageChannel(_channelName, JSONMessageCodec()), + _simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress, + _scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle, + _now = now ?? DateTime.now, + _duplicateInputGuard = + duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow); + + void start() { + if (_listening) return; + _channel.setMessageHandler(handleMessage); + _registerNativeKeyHandler(); + _listening = true; + appLogger.i('AppleTvRemoteTouchService: Listening for tvOS touch remote events'); + } + + void stop() { + if (!_listening) return; + _channel.setMessageHandler(null); + _unregisterNativeKeyHandler(); + _duplicateInputGuard.clear(); + _resetTouch(); + _listening = false; + } + + bool handleNativeKeyEvent(KeyEvent event) { + _log('native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)}'); + if (event is KeyDownEvent && _isDirectionalKey(event.logicalKey)) { + _lastDirectionalInputAt = _now(); + } + return _duplicateInputGuard.handleNativeKeyEvent(event); + } + + Future handleMessage(dynamic arguments) async { + if (arguments is! Map) { + _log('ignore message reason=not-map valueType=${arguments.runtimeType}'); + return; + } + + final type = arguments['type']; + if (type is! String) { + _log('ignore message reason=missing-type args=$arguments'); + return; + } + + _logTouch(type, arguments); + + switch (type) { + case 'started': + final position = _positionFrom(arguments); + if (position == null) return; + _startTouch(position.$1, position.$2); + case 'move': + final position = _positionFrom(arguments); + if (position == null) return; + _moveTouch(position.$1, position.$2); + case 'ended': + final position = _positionFrom(arguments); + if (position == null) { + _resetTouch(); + return; + } + _moveTouch(position.$1, position.$2); + _resetTouch(); + case 'cancelled': + _resetTouch(); + case 'click_e': + _emitSelect(); + case 'click_s': + case 'loc': + break; + default: + break; + } + } + + (double, double)? _positionFrom(Map arguments) { + final x = _toDouble(arguments['x']); + final y = _toDouble(arguments['y']); + if (x == null || y == null) return null; + return (x, y); + } + + double? _toDouble(Object? value) { + if (value is num) return value.toDouble(); + return null; + } + + void _startTouch(double x, double y) { + _touchActive = true; + _anchorX = x; + _anchorY = y; + _lastSwipeAt = null; + } + + void _moveTouch(double x, double y) { + if (!_touchActive) { + _log('ignore touch-move reason=no-active-touch x=${_formatDouble(x)} y=${_formatDouble(y)}'); + return; + } + + final deltaX = _anchorX - x; + final deltaY = _anchorY - y; + if (deltaX.abs() < swipeThreshold && deltaY.abs() < swipeThreshold) return; + + final now = _now(); + final lastSwipeAt = _lastSwipeAt; + if (lastSwipeAt != null && now.difference(lastSwipeAt) < swipeRepeatInterval) { + final age = now.difference(lastSwipeAt).inMilliseconds; + _log( + 'suppress swipe reason=repeat-cooldown age=${age}ms dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}', + ); + return; + } + + final logicalKey = deltaX.abs() >= deltaY.abs() + ? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight) + : (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown); + + _emitKey(logicalKey, source: 'swipe', detail: 'dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}'); + _anchorX = x; + _anchorY = y; + _lastSwipeAt = now; + } + + void _emitSelect() { + 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'); + return; + } + + final lastSyntheticSelectAt = _lastSyntheticSelectAt; + 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', + ); + return; + } + + if (_emitKey(LogicalKeyboardKey.enter, source: 'click_e')) { + _lastSyntheticSelectAt = now; + } + } + + bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) { + if (_duplicateInputGuard.shouldSuppressSyntheticKey(logicalKey)) { + _log('suppress key=${_keyName(logicalKey)} source=$source reason=recent-native'); + return false; + } + + _setTraditionalFocusHighlight(); + _scheduleFrame(); + _log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}'); + if (_isDirectionalKey(logicalKey)) { + _lastDirectionalInputAt = _now(); + } + _simulateKeyPress(logicalKey); + return true; + } + + Duration get duplicateSuppressionWindow => _duplicateInputGuard.suppressionWindow; + + void _resetTouch() { + _touchActive = false; + _lastSwipeAt = null; + } + + void _registerNativeKeyHandler() { + if (_nativeKeyHandlerRegistered) return; + HardwareKeyboard.instance.addHandler(handleNativeKeyEvent); + _nativeKeyHandlerRegistered = true; + } + + void _unregisterNativeKeyHandler() { + if (!_nativeKeyHandlerRegistered) return; + HardwareKeyboard.instance.removeHandler(handleNativeKeyEvent); + _nativeKeyHandlerRegistered = false; + } + + void _setTraditionalFocusHighlight() { + if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + } + } + + void _logTouch(String type, Map arguments) { + final x = _toDouble(arguments['x']); + final y = _toDouble(arguments['y']); + _log('touch type=$type x=${_formatDouble(x)} y=${_formatDouble(y)} active=$_touchActive'); + } + + void _log(String message) { + appLogger.d('AppleTvRemoteTouchService: $message'); + } + + String _eventTypeName(KeyEvent event) { + if (event is KeyDownEvent) return 'keydown'; + if (event is KeyRepeatEvent) return 'keyrepeat'; + if (event is KeyUpEvent) return 'keyup'; + return event.runtimeType.toString(); + } + + String _keyName(LogicalKeyboardKey key) { + if (key == LogicalKeyboardKey.arrowUp) return 'arrowUp'; + if (key == LogicalKeyboardKey.arrowDown) return 'arrowDown'; + if (key == LogicalKeyboardKey.arrowLeft) return 'arrowLeft'; + if (key == LogicalKeyboardKey.arrowRight) return 'arrowRight'; + if (key == LogicalKeyboardKey.enter) return 'enter'; + if (key == LogicalKeyboardKey.select) return 'select'; + if (key == LogicalKeyboardKey.gameButtonA) return 'gameButtonA'; + if (key == LogicalKeyboardKey.escape) return 'escape'; + return '0x${key.keyId.toRadixString(16)}'; + } + + bool _isDirectionalKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight; + } + + String _formatDouble(double? value) { + if (value == null) return 'n/a'; + return value.toStringAsFixed(1); + } +} diff --git a/lib/utils/key_event_simulator.dart b/lib/utils/key_event_simulator.dart index 24dc1d71..93801960 100644 --- a/lib/utils/key_event_simulator.dart +++ b/lib/utils/key_event_simulator.dart @@ -4,8 +4,8 @@ import 'package:flutter/widgets.dart'; /// Shared utility for simulating key press events through the focus tree. /// -/// Used by both [CompanionRemoteReceiver] and [GamepadService] to translate -/// external input (remote commands, gamepad buttons) into focus-tree key events. +/// Used by companion remotes, Apple TV touch input, and gamepad services to +/// translate external input into focus-tree key events. void simulateKeyPress(LogicalKeyboardKey logicalKey) { SchedulerBinding.instance.addPostFrameCallback((_) { final focusNode = FocusManager.instance.primaryFocus; @@ -60,6 +60,7 @@ PhysicalKeyboardKey _getPhysicalKey(LogicalKeyboardKey logicalKey) { if (logicalKey == LogicalKeyboardKey.arrowLeft) return PhysicalKeyboardKey.arrowLeft; if (logicalKey == LogicalKeyboardKey.arrowRight) return PhysicalKeyboardKey.arrowRight; if (logicalKey == LogicalKeyboardKey.enter) return PhysicalKeyboardKey.enter; + if (logicalKey == LogicalKeyboardKey.select) return PhysicalKeyboardKey.select; if (logicalKey == LogicalKeyboardKey.escape) return PhysicalKeyboardKey.escape; if (logicalKey == LogicalKeyboardKey.space) return PhysicalKeyboardKey.space; if (logicalKey == LogicalKeyboardKey.contextMenu) return PhysicalKeyboardKey.contextMenu; diff --git a/pubspec.yaml b/pubspec.yaml index 9b14081a..215d2870 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plezy description: "A beautiful Plex client for Flutter" publish_to: "none" -version: 2.0.0+80 +version: 2.0.0+82 environment: sdk: ">=3.10.7 <4.0.0" diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart new file mode 100644 index 00000000..f27c8b5c --- /dev/null +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/apple_tv_remote_touch_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AppleTvRemoteTouchService', () { + test('emits repeated horizontal swipes only after the repeat interval', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 380, y: 490); + await harness.send('move', x: 260, y: 490); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + + harness.advance(const Duration(milliseconds: 141)); + await harness.send('move', x: 260, y: 490); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]); + }); + + test('uses the dominant vertical axis for swipes', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 540, y: 380); + + expect(harness.keys, [LogicalKeyboardKey.arrowUp]); + }); + + test('short touch without a click event does not emit select', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('ended', x: 512, y: 504); + + expect(harness.keys, isEmpty); + }); + + test('short touch around a native directional key does not emit select', () async { + final harness = _Harness(); + + harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft)); + await harness.send('started', x: 500, y: 500); + await harness.send('ended', x: 500, y: 500); + + expect(harness.keys, isEmpty); + }); + + test('swipe end does not also emit select', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 380, y: 500); + await harness.send('ended', x: 380, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + }); + + test('deduplicates touch tap and click fallback select events', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('ended', x: 500, y: 500); + await harness.send('click_e'); + + expect(harness.keys, [LogicalKeyboardKey.enter]); + + harness.advance(const Duration(milliseconds: 121)); + await harness.send('click_e'); + + expect(harness.keys, [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_e'); + + expect(harness.keys, isEmpty); + + harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)); + harness.advance(const Duration(milliseconds: 121)); + await harness.send('click_e'); + + expect(harness.keys, [LogicalKeyboardKey.enter]); + }); + + test('recent directional input suppresses click fallback', () async { + final harness = _Harness(); + + harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft)); + await harness.send('click_e'); + + expect(harness.keys, isEmpty); + + harness.advance(const Duration(milliseconds: 221)); + await harness.send('click_e'); + + expect(harness.keys, [LogicalKeyboardKey.enter]); + }); + + test('synthetic swipe suppresses click fallback', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 380, y: 500); + await harness.send('click_e'); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + }); + + test('cancelled touch does not emit select on a later ended message', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('cancelled'); + await harness.send('ended', x: 500, y: 500); + await harness.send('loc', x: 1, y: 0); + + expect(harness.keys, isEmpty); + }); + }); +} + +class _Harness { + DateTime now = DateTime(2026, 5, 5, 12); + final List keys = []; + + late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService( + simulateKeyPress: keys.add, + scheduleFrame: () {}, + now: () => now, + swipeThreshold: 100, + ); + + Future send(String type, {double x = 0, double y = 0}) { + return service.handleMessage({'type': type, 'x': x, 'y': y}); + } + + void advance(Duration duration) { + now = now.add(duration); + } +} + +KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) { + return KeyDownEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero); +} + +KeyUpEvent _keyUp(LogicalKeyboardKey logicalKey) { + return KeyUpEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero); +} diff --git a/tvos/engine.version b/tvos/engine.version index 98c9925d..81f36e77 100644 --- a/tvos/engine.version +++ b/tvos/engine.version @@ -1 +1 @@ -3.41.6+1 +3.41.6+5