fix(tv): pause gamepad during native text input

This commit is contained in:
edde746
2026-05-19 12:10:49 +02:00
parent d2650667b8
commit 423be62218
5 changed files with 51 additions and 3 deletions
+3
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../services/gamepad_service.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import '../widgets/tv_virtual_keyboard.dart'; import '../widgets/tv_virtual_keyboard.dart';
import 'dpad_navigator.dart'; import 'dpad_navigator.dart';
@@ -24,6 +25,7 @@ class _NativeTvTextInputFocusBridge {
} }
if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) { if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) {
_focusedTokens.clear();
_lastSentFocused = false; _lastSentFocused = false;
return; return;
} }
@@ -31,6 +33,7 @@ class _NativeTvTextInputFocusBridge {
final anyFocused = _focusedTokens.isNotEmpty; final anyFocused = _focusedTokens.isNotEmpty;
if (_lastSentFocused == anyFocused) return; if (_lastSentFocused == anyFocused) return;
_lastSentFocused = anyFocused; _lastSentFocused = anyFocused;
unawaited(GamepadService.setNativeTextInputFocused(anyFocused));
unawaited(_sendFocused(anyFocused)); unawaited(_sendFocused(anyFocused));
} }
+37
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.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 // Whether the app window is currently focused — ignore gamepad input when false
bool _windowFocused = true; bool _windowFocused = true;
bool _nativeKeyHandlerRegistered = false; bool _nativeKeyHandlerRegistered = false;
bool _nativeTextInputFocused = false;
@visibleForTesting
static Future<void> Function(bool focused)? debugNativeTextInputFocusHandler;
GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard}) GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard})
: _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows); : _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows);
@@ -159,6 +164,10 @@ class GamepadService with WindowListener {
return _instance!; return _instance!;
} }
static Future<void> setNativeTextInputFocused(bool focused) {
return instance._setNativeTextInputFocused(focused);
}
/// Start listening to gamepad events. /// Start listening to gamepad events.
/// Only active on desktop platforms (macOS, Windows, Linux). /// Only active on desktop platforms (macOS, Windows, Linux).
static bool get _isDesktop => PlatformDetector.isDesktopOS(); static bool get _isDesktop => PlatformDetector.isDesktopOS();
@@ -255,6 +264,34 @@ class GamepadService with WindowListener {
return _duplicateInputGuard.handleNativeKeyEvent(event); return _duplicateInputGuard.handleNativeKeyEvent(event);
} }
Future<void> _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) { void _handleGamepadEvent(GamepadEvent event) {
switch (event) { switch (event) {
case final GamepadConnectionEvent e: case final GamepadConnectionEvent e:
+2 -2
View File
@@ -1288,10 +1288,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: universal_gamepad name: universal_gamepad
sha256: "3ada9b26e3b3471adc414fd50b8a27d2f890301d9d4cfd7a46732988a9f143d0" sha256: "1373d86676b938f27497a58f61cdf3d3a3e61b849b69c006774182fd7c0f550b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.5" version: "1.5.6"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:
+1 -1
View File
@@ -44,7 +44,7 @@ dependencies:
path: wakelock_plus path: wakelock_plus
path_provider: ^2.1.0 path_provider: ^2.1.0
path: ^1.9.0 path: ^1.9.0
universal_gamepad: ^1.5.0 universal_gamepad: ^1.5.6
drift: ^2.31.0 drift: ^2.31.0
sqlite3_flutter_libs: ^0.5.42 sqlite3_flutter_libs: ^0.5.42
crypto: ^3.0.7 crypto: ^3.0.7
@@ -4,12 +4,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_text_field.dart'; import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/services/gamepad_service.dart';
import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/utils/platform_detector.dart';
void main() { void main() {
tearDown(() { tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false); TvDetectionService.setForceTVSync(false);
GamepadService.debugNativeTextInputFocusHandler = null;
}); });
testWidgets('tab traversal focuses the text form field', (tester) async { testWidgets('tab traversal focuses the text form field', (tester) async {
@@ -343,6 +345,10 @@ void main() {
TvDetectionService.setForceTVSync(true); TvDetectionService.setForceTVSync(true);
const channel = MethodChannel('com.plezy/text_input'); const channel = MethodChannel('com.plezy/text_input');
final calls = <MethodCall>[]; final calls = <MethodCall>[];
final gamepadFocusStates = <bool>[];
GamepadService.debugNativeTextInputFocusHandler = (focused) async {
gamepadFocusStates.add(focused);
};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call); calls.add(call);
return null; return null;
@@ -377,6 +383,7 @@ void main() {
expect(calls.last.method, 'setNativeTextInputFocused'); expect(calls.last.method, 'setNativeTextInputFocused');
expect(calls.last.arguments, isTrue); expect(calls.last.arguments, isTrue);
expect(gamepadFocusStates, [true]);
otherFocusNode.requestFocus(); otherFocusNode.requestFocus();
await tester.pump(); await tester.pump();
@@ -384,6 +391,7 @@ void main() {
expect(calls.last.method, 'setNativeTextInputFocused'); expect(calls.last.method, 'setNativeTextInputFocused');
expect(calls.last.arguments, isFalse); expect(calls.last.arguments, isFalse);
expect(gamepadFocusStates, [true, false]);
}); });
testWidgets('Android TV physical keyboard still uses field navigation', (tester) async { testWidgets('Android TV physical keyboard still uses field navigation', (tester) async {