fix(windows): suppress Steam Input duplicate actions
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user