diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 5ee4311c..4eab6cbc 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -11,14 +11,109 @@ import '../utils/app_logger.dart'; import '../utils/key_event_simulator.dart' as key_sim; import '../utils/platform_detector.dart'; +/// Suppresses synthetic gamepad key events when the OS has just delivered an +/// equivalent native key event, which happens with Steam Input on Windows. +class GamepadDuplicateInputGuard { + static const defaultSuppressionWindow = Duration(milliseconds: 120); + + static final Map> _nativeAliasesBySyntheticKey = { + LogicalKeyboardKey.arrowUp: {LogicalKeyboardKey.arrowUp}, + LogicalKeyboardKey.arrowDown: {LogicalKeyboardKey.arrowDown}, + LogicalKeyboardKey.arrowLeft: {LogicalKeyboardKey.arrowLeft}, + LogicalKeyboardKey.arrowRight: {LogicalKeyboardKey.arrowRight}, + LogicalKeyboardKey.enter: { + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.select, + LogicalKeyboardKey.gameButtonA, + }, + LogicalKeyboardKey.escape: { + LogicalKeyboardKey.escape, + LogicalKeyboardKey.goBack, + LogicalKeyboardKey.browserBack, + LogicalKeyboardKey.gameButtonB, + }, + LogicalKeyboardKey.gameButtonX: {LogicalKeyboardKey.gameButtonX, LogicalKeyboardKey.contextMenu}, + }; + + static final Set _trackedNativeKeys = _nativeAliasesBySyntheticKey.values + .expand((keys) => keys) + .toSet(); + + final DateTime Function() _now; + final bool Function()? _enabled; + final Duration suppressionWindow; + final Map _lastNativeEvents = {}; + final Set _nativeKeysPressed = {}; + + GamepadDuplicateInputGuard({ + DateTime Function()? now, + bool Function()? enabled, + this.suppressionWindow = defaultSuppressionWindow, + }) : _now = now ?? DateTime.now, + _enabled = enabled; + + bool get _isEnabled => _enabled?.call() ?? true; + + bool handleNativeKeyEvent(KeyEvent event) { + if (!_isEnabled || !_trackedNativeKeys.contains(event.logicalKey)) return false; + + final now = _now(); + _lastNativeEvents[event.logicalKey] = now; + if (event is KeyUpEvent) { + _nativeKeysPressed.remove(event.logicalKey); + } else { + _nativeKeysPressed.add(event.logicalKey); + } + _prune(now); + return false; + } + + bool shouldSuppressSyntheticKey(LogicalKeyboardKey logicalKey) { + if (!_isEnabled) return false; + + final now = _now(); + _prune(now); + for (final key in _nativeAliasesBySyntheticKey[logicalKey] ?? {logicalKey}) { + if (_nativeKeysPressed.contains(key)) return true; + + final lastNativeEvent = _lastNativeEvents[key]; + if (lastNativeEvent != null && now.difference(lastNativeEvent) <= suppressionWindow) { + return true; + } + } + return false; + } + + void clear() { + _lastNativeEvents.clear(); + _nativeKeysPressed.clear(); + } + + void _prune(DateTime now) { + _lastNativeEvents.removeWhere((_, timestamp) => now.difference(timestamp) > suppressionWindow); + } +} + /// Service that bridges gamepad input to Flutter's focus navigation system. /// /// Listens to gamepad events from the `universal_gamepad` package and translates /// them into focus navigation actions and key events that integrate with the /// existing keyboard navigation system. class GamepadService with WindowListener { + static final Map _syntheticKeyByButton = { + GamepadButton.dpadUp: LogicalKeyboardKey.arrowUp, + GamepadButton.dpadDown: LogicalKeyboardKey.arrowDown, + GamepadButton.dpadLeft: LogicalKeyboardKey.arrowLeft, + GamepadButton.dpadRight: LogicalKeyboardKey.arrowRight, + GamepadButton.a: LogicalKeyboardKey.enter, + GamepadButton.b: LogicalKeyboardKey.escape, + GamepadButton.x: LogicalKeyboardKey.gameButtonX, + }; + static GamepadService? _instance; StreamSubscription? _subscription; + final GamepadDuplicateInputGuard _duplicateInputGuard; /// Callback to switch InputModeTracker to keyboard mode. /// Set by InputModeTracker when it initializes. @@ -49,11 +144,14 @@ class GamepadService with WindowListener { // Track button states to prevent repeated events from button holds final Set _pressedButtons = {}; + final Set _suppressedButtons = {}; // Whether the app window is currently focused — ignore gamepad input when false bool _windowFocused = true; + bool _nativeKeyHandlerRegistered = false; - GamepadService._(); + GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard}) + : _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows); /// Get the singleton instance. static GamepadService get instance { @@ -85,6 +183,7 @@ class GamepadService with WindowListener { windowManager.addListener(this); _windowFocused = await windowManager.isFocused(); } + _registerNativeKeyHandler(); unawaited(_subscription?.cancel()); _subscription = Gamepad.instance.events.listen( @@ -97,8 +196,11 @@ class GamepadService with WindowListener { /// Stop listening to gamepad events. void stop() { _stopDirectionRepeat(); + _unregisterNativeKeyHandler(); _subscription?.cancel(); _subscription = null; + _duplicateInputGuard.clear(); + _suppressedButtons.clear(); if (_isDesktop) { windowManager.removeListener(this); } @@ -108,6 +210,7 @@ class GamepadService with WindowListener { @override void onWindowFocus() { _windowFocused = true; + _duplicateInputGuard.clear(); Gamepad.instance.resume(); } @@ -125,6 +228,8 @@ class GamepadService with WindowListener { _simulateKeyUp(LogicalKeyboardKey.gameButtonX); } _pressedButtons.clear(); + _suppressedButtons.clear(); + _duplicateInputGuard.clear(); // Reset analog stick state so re-focus doesn't inherit stale direction _leftStickUp = false; @@ -136,6 +241,22 @@ class GamepadService with WindowListener { Gamepad.instance.pause(); } + void _registerNativeKeyHandler() { + if (_nativeKeyHandlerRegistered || !Platform.isWindows) return; + HardwareKeyboard.instance.addHandler(_handleNativeKeyEvent); + _nativeKeyHandlerRegistered = true; + } + + void _unregisterNativeKeyHandler() { + if (!_nativeKeyHandlerRegistered) return; + HardwareKeyboard.instance.removeHandler(_handleNativeKeyEvent); + _nativeKeyHandlerRegistered = false; + } + + bool _handleNativeKeyEvent(KeyEvent event) { + return _duplicateInputGuard.handleNativeKeyEvent(event); + } + void _handleGamepadEvent(GamepadEvent event) { switch (event) { case final GamepadConnectionEvent e: @@ -165,6 +286,10 @@ class GamepadService with WindowListener { if (event.pressed && !wasPressed) { _pressedButtons.add(event.button); + if (_shouldSuppressButton(event.button)) { + _suppressedButtons.add(event.button); + return; + } // D-pad — navigate with auto-repeat while held switch (event.button) { @@ -198,6 +323,7 @@ class GamepadService with WindowListener { } } else if (!event.pressed && wasPressed) { _pressedButtons.remove(event.button); + if (_suppressedButtons.remove(event.button)) return; switch (event.button) { // D-pad release — stop repeat @@ -217,6 +343,11 @@ class GamepadService with WindowListener { } } + bool _shouldSuppressButton(GamepadButton button) { + final syntheticKey = _syntheticKeyByButton[button]; + return syntheticKey != null && _duplicateInputGuard.shouldSuppressSyntheticKey(syntheticKey); + } + void _handleAxis(GamepadAxisEvent event) { if (!_windowFocused) return; diff --git a/test/services/gamepad_duplicate_input_guard_test.dart b/test/services/gamepad_duplicate_input_guard_test.dart new file mode 100644 index 00000000..54844d6b --- /dev/null +++ b/test/services/gamepad_duplicate_input_guard_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/gamepad_service.dart'; + +void main() { + group('GamepadDuplicateInputGuard', () { + late DateTime now; + late GamepadDuplicateInputGuard guard; + + setUp(() { + now = DateTime(2026, 4, 26, 12); + guard = GamepadDuplicateInputGuard(now: () => now, suppressionWindow: const Duration(milliseconds: 100)); + }); + + test('suppresses a matching D-pad event after a native arrow key', () { + guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowRight)); + + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.arrowRight), isTrue); + }); + + test('does not suppress D-pad input when no native key was seen', () { + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.arrowRight), isFalse); + }); + + test('suppresses gamepad A while native enter is down and just after release', () { + guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.enter)); + + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.enter), isTrue); + + guard.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.enter)); + now = now.add(const Duration(milliseconds: 50)); + + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.enter), isTrue); + }); + + test('suppression expires after the debounce window', () { + guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowRight)); + guard.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.arrowRight)); + + now = now.add(const Duration(milliseconds: 101)); + + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.arrowRight), isFalse); + }); + + test('unrelated native keys do not suppress controller input', () { + guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.space)); + + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.arrowRight), isFalse); + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.enter), isFalse); + }); + + test('uses aliases for Steam Input mapped context menu and back keys', () { + guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.contextMenu)); + + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.gameButtonX), isTrue); + + guard.clear(); + guard.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.browserBack)); + + expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.escape), isTrue); + }); + }); +} + +KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) { + return KeyDownEvent( + physicalKey: _physicalKeyFor(logicalKey), + logicalKey: logicalKey, + timeStamp: const Duration(milliseconds: 1), + ); +} + +KeyUpEvent _keyUp(LogicalKeyboardKey logicalKey) { + return KeyUpEvent( + physicalKey: _physicalKeyFor(logicalKey), + logicalKey: logicalKey, + timeStamp: const Duration(milliseconds: 1), + ); +} + +PhysicalKeyboardKey _physicalKeyFor(LogicalKeyboardKey logicalKey) { + if (logicalKey == LogicalKeyboardKey.arrowRight) return PhysicalKeyboardKey.arrowRight; + if (logicalKey == LogicalKeyboardKey.enter) return PhysicalKeyboardKey.enter; + if (logicalKey == LogicalKeyboardKey.space) return PhysicalKeyboardKey.space; + if (logicalKey == LogicalKeyboardKey.contextMenu) return PhysicalKeyboardKey.contextMenu; + if (logicalKey == LogicalKeyboardKey.browserBack) return PhysicalKeyboardKey.browserBack; + return PhysicalKeyboardKey.enter; +}