fix(windows): suppress Steam Input duplicate actions

This commit is contained in:
edde746
2026-04-26 20:46:25 +02:00
parent 19a0128363
commit d6b47f9d51
2 changed files with 220 additions and 1 deletions
+132 -1
View File
@@ -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<LogicalKeyboardKey, Set<LogicalKeyboardKey>> _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<LogicalKeyboardKey> _trackedNativeKeys = _nativeAliasesBySyntheticKey.values
.expand((keys) => keys)
.toSet();
final DateTime Function() _now;
final bool Function()? _enabled;
final Duration suppressionWindow;
final Map<LogicalKeyboardKey, DateTime> _lastNativeEvents = {};
final Set<LogicalKeyboardKey> _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<GamepadButton, LogicalKeyboardKey> _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<GamepadEvent>? _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<GamepadButton> _pressedButtons = {};
final Set<GamepadButton> _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;