From d2650667b86c3fed0815d5c3e166aa945f4bb76b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 19 May 2026 11:17:44 +0200 Subject: [PATCH] fix(tv): preserve native text input routing --- .../kotlin/com/edde746/plezy/MainActivity.kt | 26 +++++++- lib/focus/focusable_text_field.dart | 61 ++++++++++++++++++- lib/focus/focusable_wrapper.dart | 4 +- lib/services/gamepad_service.dart | 3 +- lib/utils/key_event_simulator.dart | 5 +- test/utils/key_event_simulator_test.dart | 39 ++++++++++++ test/widgets/focusable_text_field_test.dart | 49 +++++++++++++++ 7 files changed, 178 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index dbd41dcc..bdbbe536 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -18,6 +18,7 @@ import android.util.Rational import android.view.KeyEvent import android.view.TextureView import android.view.ViewGroup +import android.view.WindowInsets import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import androidx.core.content.FileProvider @@ -46,9 +47,11 @@ class MainActivity : FlutterActivity() { private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player" private val THEME_CHANNEL = "com.plezy/theme" private val DEVICE_CHANNEL = "com.plezy/device" + private val TEXT_INPUT_CHANNEL = "com.plezy/text_input" private val APP_EXIT_CHANNEL = "com.plezy/app_exit" private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground" private var watchNextPlugin: WatchNextPlugin? = null + private var nativeTextInputFocused = false // Auto PiP state private var autoPipReady = false @@ -57,6 +60,16 @@ class MainActivity : FlutterActivity() { private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean + private fun isImeVisible(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + return window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.ime()) == true + } + + private fun shouldForwardDpadBeforeIme(): Boolean { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + return !nativeTextInputFocused && !isImeVisible() && !imm.isAcceptingText + } + private fun getAndroidTvDetection(): Map { val pm = packageManager val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK @@ -124,8 +137,7 @@ class MainActivity : FlutterActivity() { KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER -> { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - if (!imm.isAcceptingText) { + if (shouldForwardDpadBeforeIme()) { super.dispatchKeyEvent(event) return true } @@ -250,6 +262,16 @@ class MainActivity : FlutterActivity() { } } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TEXT_INPUT_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "setNativeTextInputFocused" -> { + nativeTextInputFocused = call.arguments as? Boolean ?: false + result.success(null) + } + else -> result.notImplemented() + } + } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_EXIT_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "requestExit" -> { diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 3542c1a8..92d3d5c9 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -9,6 +11,40 @@ bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetec String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText; +class _NativeTvTextInputFocusBridge { + static const _channel = MethodChannel('com.plezy/text_input'); + static final Set _focusedTokens = {}; + static bool _lastSentFocused = false; + + static void setFocused(Object token, bool focused) { + if (focused) { + _focusedTokens.add(token); + } else { + _focusedTokens.remove(token); + } + + if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) { + _lastSentFocused = false; + return; + } + + final anyFocused = _focusedTokens.isNotEmpty; + if (_lastSentFocused == anyFocused) return; + _lastSentFocused = anyFocused; + unawaited(_sendFocused(anyFocused)); + } + + static Future _sendFocused(bool focused) async { + try { + await _channel.invokeMethod('setNativeTextInputFocused', focused); + } on MissingPluginException { + // Tests and non-Android embedders do not register this channel. + } on PlatformException { + // Focus reporting is a best-effort native routing hint. + } + } +} + KeyEventResult _handleInputKey({ required TextEditingController controller, required bool usesTvKeyboard, @@ -528,6 +564,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { FocusNode? _installedFocusNode; FocusOnKeyEventCallback? _previousOnKeyEvent; late final FocusOnKeyEventCallback _keyHandler = _handleKey; + late final VoidCallback _focusListener = _syncNativeTextInputFocus; + final Object _nativeFocusToken = Object(); + bool _reportedNativeTextInputFocused = false; FocusNode get _effectiveFocusNode => widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput')); @@ -538,6 +577,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { if (oldWidget.input.focusNode != widget.input.focusNode) { _restoreInstalledHandler(); } + _syncNativeTextInputFocus(); } @override @@ -547,6 +587,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { super.dispose(); } + void _syncNativeTextInputFocus() { + final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard; + _setNativeTextInputFocused(focused); + } + + void _setNativeTextInputFocused(bool focused) { + if (_reportedNativeTextInputFocused == focused) return; + _reportedNativeTextInputFocused = focused; + _NativeTvTextInputFocusBridge.setFocused(_nativeFocusToken, focused); + } + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { final previous = _previousOnKeyEvent; if (previous != null && !identical(previous, _keyHandler)) { @@ -570,12 +621,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _installedFocusNode = node; _previousOnKeyEvent = node.onKeyEvent; node.onKeyEvent = _keyHandler; + node.addListener(_focusListener); } void _restoreInstalledHandler() { + _setNativeTextInputFocused(false); final node = _installedFocusNode; - if (node != null && identical(node.onKeyEvent, _keyHandler)) { - node.onKeyEvent = _previousOnKeyEvent; + if (node != null) { + node.removeListener(_focusListener); + if (identical(node.onKeyEvent, _keyHandler)) { + node.onKeyEvent = _previousOnKeyEvent; + } } _installedFocusNode = null; _previousOnKeyEvent = null; @@ -585,6 +641,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { Widget build(BuildContext context) { final focusNode = _effectiveFocusNode; _installKeyHandler(focusNode); + _syncNativeTextInputFocus(); return widget.builder(widget.input._hasTvKeyboard, focusNode); } } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 3711b916..ffcb0e97 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -72,7 +72,7 @@ class FocusableWrapper extends StatefulWidget { /// Whether the wrapper can receive focus. final bool canRequestFocus; - /// Custom key event handler. Return KeyEventResult.handled to consume the event. + /// Custom key event handler. Return any non-ignored result to stop default handling. /// This is called before the default key handling. final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent; @@ -325,7 +325,7 @@ class _FocusableWrapperState extends State with SingleTickerPr // Call custom key handler first if (widget.onKeyEvent != null) { final result = widget.onKeyEvent!(node, event); - if (result == KeyEventResult.handled) { + if (result != KeyEventResult.ignored) { return result; } } diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 65d82be0..84cc1dc6 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -451,7 +451,8 @@ class GamepadService with WindowListener { FocusNode? node = FocusManager.instance.primaryFocus; while (node != null) { if (node.onKeyEvent != null) { - if (node.onKeyEvent!(node, event) == KeyEventResult.handled) break; + final result = node.onKeyEvent!(node, event); + if (result != KeyEventResult.ignored) break; } node = node.parent; } diff --git a/lib/utils/key_event_simulator.dart b/lib/utils/key_event_simulator.dart index 88665d68..4867fb95 100644 --- a/lib/utils/key_event_simulator.dart +++ b/lib/utils/key_event_simulator.dart @@ -84,8 +84,9 @@ void simulateKeyUp(LogicalKeyboardKey logicalKey) { void _dispatchKeyEvent(FocusNode focusNode, KeyEvent event) { FocusNode? node = focusNode; while (node != null) { - if (node.onKeyEvent != null && node.onKeyEvent!(node, event) == KeyEventResult.handled) { - break; + if (node.onKeyEvent != null) { + final result = node.onKeyEvent!(node, event); + if (result != KeyEventResult.ignored) break; } node = node.parent; } diff --git a/test/utils/key_event_simulator_test.dart b/test/utils/key_event_simulator_test.dart index 1bf56448..375a7957 100644 --- a/test/utils/key_event_simulator_test.dart +++ b/test/utils/key_event_simulator_test.dart @@ -31,6 +31,45 @@ void main() { expect(events[1], isA()); expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad)); }); + + testWidgets('simulateKeyPress stops at skipRemainingHandlers', (tester) async { + final childEvents = []; + final parentEvents = []; + late BuildContext childContext; + + await tester.pumpWidget( + MaterialApp( + home: Focus( + onKeyEvent: (_, event) { + parentEvents.add(event); + return KeyEventResult.handled; + }, + child: Focus( + autofocus: true, + onKeyEvent: (_, event) { + childEvents.add(event); + return KeyEventResult.skipRemainingHandlers; + }, + child: Builder( + builder: (context) { + childContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + Focus.of(childContext).requestFocus(); + await tester.pump(); + + simulateKeyPress(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(); + + expect(childEvents, hasLength(2)); + expect(parentEvents, isEmpty); + }); } Future> _pumpKeyEventRecorder(WidgetTester tester) async { diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index e099e292..64a34e62 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -337,6 +337,55 @@ void main() { expect(find.byType(Dialog), findsNothing); }); + testWidgets('Android TV native text input focus is reported to platform', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + const channel = MethodChannel('com.plezy/text_input'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null), + ); + + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); + final otherFocusNode = FocusNode(debugLabel: 'other'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + addTearDown(otherFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextFormField(controller: controller, focusNode: fieldFocusNode), + Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()), + ], + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(calls.last.method, 'setNativeTextInputFocused'); + expect(calls.last.arguments, isTrue); + + otherFocusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(calls.last.method, 'setNativeTextInputFocused'); + expect(calls.last.arguments, isFalse); + }); + testWidgets('Android TV physical keyboard still uses field navigation', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true);