From 423be6221828a5eeeb9c0dc29674a8d22efcb6ee Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 19 May 2026 12:10:49 +0200 Subject: [PATCH] fix(tv): pause gamepad during native text input --- lib/focus/focusable_text_field.dart | 3 ++ lib/services/gamepad_service.dart | 37 +++++++++++++++++++++ pubspec.lock | 4 +-- pubspec.yaml | 2 +- test/widgets/focusable_text_field_test.dart | 8 +++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 92d3d5c9..e77f1001 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../services/gamepad_service.dart'; import '../utils/platform_detector.dart'; import '../widgets/tv_virtual_keyboard.dart'; import 'dpad_navigator.dart'; @@ -24,6 +25,7 @@ class _NativeTvTextInputFocusBridge { } if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) { + _focusedTokens.clear(); _lastSentFocused = false; return; } @@ -31,6 +33,7 @@ class _NativeTvTextInputFocusBridge { final anyFocused = _focusedTokens.isNotEmpty; if (_lastSentFocused == anyFocused) return; _lastSentFocused = anyFocused; + unawaited(GamepadService.setNativeTextInputFocused(anyFocused)); unawaited(_sendFocused(anyFocused)); } diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 84cc1dc6..07620dbd 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -150,6 +151,10 @@ class GamepadService with WindowListener { // Whether the app window is currently focused — ignore gamepad input when false bool _windowFocused = true; bool _nativeKeyHandlerRegistered = false; + bool _nativeTextInputFocused = false; + + @visibleForTesting + static Future Function(bool focused)? debugNativeTextInputFocusHandler; GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard}) : _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows); @@ -159,6 +164,10 @@ class GamepadService with WindowListener { return _instance!; } + static Future setNativeTextInputFocused(bool focused) { + return instance._setNativeTextInputFocused(focused); + } + /// Start listening to gamepad events. /// Only active on desktop platforms (macOS, Windows, Linux). static bool get _isDesktop => PlatformDetector.isDesktopOS(); @@ -255,6 +264,34 @@ class GamepadService with WindowListener { return _duplicateInputGuard.handleNativeKeyEvent(event); } + Future _setNativeTextInputFocused(bool focused) async { + if (_nativeTextInputFocused == focused) return; + _nativeTextInputFocused = focused; + + if (focused) { + _stopDirectionRepeat(); + _pressedButtons.clear(); + _suppressedButtons.clear(); + _duplicateInputGuard.clear(); + } + + final debugHandler = debugNativeTextInputFocusHandler; + if (debugHandler != null) { + await debugHandler(focused); + return; + } + + try { + if (focused) { + await Gamepad.instance.pause(); + } else { + await Gamepad.instance.resume(); + } + } catch (e) { + appLogger.e('GamepadService: Failed to ${focused ? "pause" : "resume"} for native text input', error: e); + } + } + void _handleGamepadEvent(GamepadEvent event) { switch (event) { case final GamepadConnectionEvent e: diff --git a/pubspec.lock b/pubspec.lock index edc70dfc..2ec546d5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1288,10 +1288,10 @@ packages: dependency: "direct main" description: name: universal_gamepad - sha256: "3ada9b26e3b3471adc414fd50b8a27d2f890301d9d4cfd7a46732988a9f143d0" + sha256: "1373d86676b938f27497a58f61cdf3d3a3e61b849b69c006774182fd7c0f550b" url: "https://pub.dev" source: hosted - version: "1.5.5" + version: "1.5.6" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a01942a9..d5a927da 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: path: wakelock_plus path_provider: ^2.1.0 path: ^1.9.0 - universal_gamepad: ^1.5.0 + universal_gamepad: ^1.5.6 drift: ^2.31.0 sqlite3_flutter_libs: ^0.5.42 crypto: ^3.0.7 diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 64a34e62..967d5788 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -4,12 +4,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/focusable_text_field.dart'; +import 'package:plezy/services/gamepad_service.dart'; import 'package:plezy/utils/platform_detector.dart'; void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.setForceTVSync(false); + GamepadService.debugNativeTextInputFocusHandler = null; }); testWidgets('tab traversal focuses the text form field', (tester) async { @@ -343,6 +345,10 @@ void main() { TvDetectionService.setForceTVSync(true); const channel = MethodChannel('com.plezy/text_input'); final calls = []; + final gamepadFocusStates = []; + GamepadService.debugNativeTextInputFocusHandler = (focused) async { + gamepadFocusStates.add(focused); + }; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { calls.add(call); return null; @@ -377,6 +383,7 @@ void main() { expect(calls.last.method, 'setNativeTextInputFocused'); expect(calls.last.arguments, isTrue); + expect(gamepadFocusStates, [true]); otherFocusNode.requestFocus(); await tester.pump(); @@ -384,6 +391,7 @@ void main() { expect(calls.last.method, 'setNativeTextInputFocused'); expect(calls.last.arguments, isFalse); + expect(gamepadFocusStates, [true, false]); }); testWidgets('Android TV physical keyboard still uses field navigation', (tester) async {