Files
plezy/lib/services/gamepad_service.dart
T

635 lines
23 KiB
Dart

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';
import 'package:universal_gamepad/universal_gamepad.dart';
import 'package:window_manager/window_manager.dart';
import '../utils/app_logger.dart';
import '../utils/key_event_simulator.dart' as key_sim;
import '../utils/platform_detector.dart';
import '../utils/text_input_diagnostics.dart';
String _describeGamepadKeyEvent(KeyEvent event) {
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}';
}
String _describeGamepadButton(GamepadButtonEvent event) {
return 'button=${event.button} pressed=${event.pressed} value=${event.value} gamepad=${event.gamepadId}';
}
String _describeGamepadAxis(GamepadAxisEvent event) {
return 'axis=${event.axis} value=${event.value} gamepad=${event.gamepadId}';
}
void _logGamepadDiag(String message) {
TextInputDiagnostics.log('GamepadService', message);
}
/// 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,
this._enabled,
this.suppressionWindow = defaultSuppressionWindow,
}) : _now = now ?? DateTime.now;
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.
static VoidCallback? onGamepadInput;
/// Callback for L1 bumper press (previous tab).
/// Screens with tabs can listen to this.
static VoidCallback? onL1Pressed;
/// Callback for R1 bumper press (next tab).
/// Screens with tabs can listen to this.
static VoidCallback? onR1Pressed;
// Deadzone for analog sticks (0.0 to 1.0)
static const double _stickDeadzone = 0.5;
// Auto-repeat timing for held directional inputs (D-pad / stick)
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
static const Duration _repeatInterval = Duration(milliseconds: 80);
Timer? _repeatTimer;
// Track stick state to detect deadzone crossings
bool _leftStickUp = false;
bool _leftStickDown = false;
bool _leftStickLeft = false;
bool _leftStickRight = false;
// 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;
bool _nativeTextInputFocused = false;
@visibleForTesting
static Future<void> Function(bool focused)? debugNativeTextInputFocusHandler;
GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard})
: _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows);
static GamepadService get instance {
_instance ??= GamepadService._();
return _instance!;
}
static Future<void> 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();
void start() async {
appLogger.i('GamepadService: Starting on ${Platform.operatingSystem}');
try {
final gamepads = await Gamepad.instance.listGamepads();
appLogger.i('GamepadService: Found ${gamepads.length} gamepad(s)');
for (final gamepad in gamepads) {
appLogger.i(' - ${gamepad.name} (id: ${gamepad.id})');
}
} catch (e) {
appLogger.e('GamepadService: Error listing gamepads', error: e);
}
// Track window focus so we ignore gamepad input when another app is active
// (window_manager is desktop-only)
if (_isDesktop) {
windowManager.addListener(this);
_windowFocused = await windowManager.isFocused();
}
_registerNativeKeyHandler();
unawaited(_subscription?.cancel());
_subscription = Gamepad.instance.events.listen(
_handleGamepadEvent,
onError: (e) => appLogger.e('GamepadService: Stream error', error: e),
);
appLogger.i('GamepadService: Listening for gamepad events');
}
void stop() {
_stopDirectionRepeat();
_unregisterNativeKeyHandler();
_subscription?.cancel();
_subscription = null;
_duplicateInputGuard.clear();
_suppressedButtons.clear();
if (_isDesktop) {
windowManager.removeListener(this);
}
Gamepad.instance.dispose();
}
@override
void onWindowFocus() {
_windowFocused = true;
_duplicateInputGuard.clear();
Gamepad.instance.resume();
}
@override
void onWindowBlur() {
_windowFocused = false;
_stopDirectionRepeat();
// Send key-up for any face buttons that are mid-hold so widgets
// waiting for the release (e.g. long-press timers) don't get stuck.
if (_pressedButtons.contains(GamepadButton.a)) {
_simulateKeyUp(LogicalKeyboardKey.enter);
}
if (_pressedButtons.contains(GamepadButton.x)) {
_simulateKeyUp(LogicalKeyboardKey.gameButtonX);
}
_pressedButtons.clear();
_suppressedButtons.clear();
_duplicateInputGuard.clear();
// Reset analog stick state so re-focus doesn't inherit stale direction
_leftStickUp = false;
_leftStickDown = false;
_leftStickLeft = false;
_leftStickRight = false;
// Release native device handles so other apps can use the gamepad.
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);
}
Future<void> _setNativeTextInputFocused(bool focused) async {
_logGamepadDiag('setNativeTextInputFocused requested focused=$focused current=$_nativeTextInputFocused');
if (_nativeTextInputFocused == focused) {
_logGamepadDiag('setNativeTextInputFocused no-op focused=$focused');
return;
}
_nativeTextInputFocused = focused;
if (focused) {
_logGamepadDiag('native text input focused; clearing repeat/buttons/duplicate guard before pause');
_stopDirectionRepeat();
_pressedButtons.clear();
_suppressedButtons.clear();
_duplicateInputGuard.clear();
}
final debugHandler = debugNativeTextInputFocusHandler;
if (debugHandler != null) {
_logGamepadDiag('setNativeTextInputFocused using debug handler focused=$focused');
await debugHandler(focused);
return;
}
try {
if (focused) {
_logGamepadDiag('calling Gamepad.pause for native text input');
await Gamepad.instance.pause();
_logGamepadDiag('Gamepad.pause completed for native text input');
} else {
_logGamepadDiag('calling Gamepad.resume after native text input');
await Gamepad.instance.resume();
_logGamepadDiag('Gamepad.resume completed after native text input');
}
} catch (e) {
appLogger.e('GamepadService: Failed to ${focused ? "pause" : "resume"} for native text input', error: e);
}
}
void _handleGamepadEvent(GamepadEvent event) {
_logGamepadDiag('event received type=${event.runtimeType} nativeTextInputFocused=$_nativeTextInputFocused');
switch (event) {
case final GamepadConnectionEvent e:
appLogger.i('GamepadService: Gamepad ${e.connected ? "connected" : "disconnected"}: ${e.info.name}');
_logGamepadDiag('connection connected=${e.connected} info=${e.info.name}/${e.info.id}');
case final GamepadButtonEvent e:
_handleButton(e);
case final GamepadAxisEvent e:
_handleAxis(e);
}
}
void _handleButton(GamepadButtonEvent event) {
_logGamepadDiag(
'button received ${_describeGamepadButton(event)} windowFocused=$_windowFocused nativeTextInputFocused=$_nativeTextInputFocused',
);
if (!_windowFocused) {
_logGamepadDiag('button ignored because window is not focused ${_describeGamepadButton(event)}');
return;
}
// Switch to keyboard mode on any button press
if (event.pressed) {
onGamepadInput?.call();
_setTraditionalFocusHighlight();
}
// Ensure a frame is scheduled so addPostFrameCallback-based key
// simulation fires promptly. Without this, key-up events can be
// delayed indefinitely when the app is idle, causing the long-press
// timer to fire before the release is delivered.
key_sim.scheduleFrameIfIdle();
final wasPressed = _pressedButtons.contains(event.button);
if (event.pressed && !wasPressed) {
_pressedButtons.add(event.button);
if (_shouldSuppressButton(event.button)) {
_logGamepadDiag('button suppressed by duplicate guard ${_describeGamepadButton(event)}');
_suppressedButtons.add(event.button);
return;
}
// D-pad — navigate with auto-repeat while held
switch (event.button) {
case GamepadButton.dpadUp:
_logGamepadDiag('button starts direction repeat up ${_describeGamepadButton(event)}');
_startDirectionRepeat(TraversalDirection.up);
return;
case GamepadButton.dpadDown:
_logGamepadDiag('button starts direction repeat down ${_describeGamepadButton(event)}');
_startDirectionRepeat(TraversalDirection.down);
return;
case GamepadButton.dpadLeft:
_logGamepadDiag('button starts direction repeat left ${_describeGamepadButton(event)}');
_startDirectionRepeat(TraversalDirection.left);
return;
case GamepadButton.dpadRight:
_logGamepadDiag('button starts direction repeat right ${_describeGamepadButton(event)}');
_startDirectionRepeat(TraversalDirection.right);
return;
// Face buttons — send KeyDown on press, KeyUp on release
// so widget-level long-press timers work naturally
case GamepadButton.a:
_logGamepadDiag('button simulates key down enter ${_describeGamepadButton(event)}');
_simulateKeyDown(LogicalKeyboardKey.enter);
case GamepadButton.x:
_logGamepadDiag('button simulates key down context/menu ${_describeGamepadButton(event)}');
_simulateKeyDown(LogicalKeyboardKey.gameButtonX);
// Immediate actions on press
case GamepadButton.b:
_logGamepadDiag('button simulates key press escape ${_describeGamepadButton(event)}');
_simulateKeyPress(LogicalKeyboardKey.escape);
case GamepadButton.leftShoulder:
onL1Pressed?.call();
case GamepadButton.rightShoulder:
onR1Pressed?.call();
default:
break;
}
} else if (!event.pressed && wasPressed) {
_pressedButtons.remove(event.button);
if (_suppressedButtons.remove(event.button)) {
_logGamepadDiag('button release consumed by suppressed set ${_describeGamepadButton(event)}');
return;
}
// D-pad release — stop repeat
switch (event.button) {
case GamepadButton.dpadUp:
case GamepadButton.dpadDown:
case GamepadButton.dpadLeft:
case GamepadButton.dpadRight:
_logGamepadDiag('button stops direction repeat ${_describeGamepadButton(event)}');
_stopDirectionRepeat();
// Face button release — send KeyUp
case GamepadButton.a:
_logGamepadDiag('button simulates key up enter ${_describeGamepadButton(event)}');
_simulateKeyUp(LogicalKeyboardKey.enter);
case GamepadButton.x:
_logGamepadDiag('button simulates key up context/menu ${_describeGamepadButton(event)}');
_simulateKeyUp(LogicalKeyboardKey.gameButtonX);
default:
break;
}
}
}
bool _shouldSuppressButton(GamepadButton button) {
final syntheticKey = _syntheticKeyByButton[button];
final suppressed = syntheticKey != null && _duplicateInputGuard.shouldSuppressSyntheticKey(syntheticKey);
_logGamepadDiag('duplicate guard button=$button syntheticKey=$syntheticKey suppressed=$suppressed');
return suppressed;
}
void _handleAxis(GamepadAxisEvent event) {
_logGamepadDiag(
'axis received ${_describeGamepadAxis(event)} windowFocused=$_windowFocused nativeTextInputFocused=$_nativeTextInputFocused',
);
if (!_windowFocused) {
_logGamepadDiag('axis ignored because window is not focused ${_describeGamepadAxis(event)}');
return;
}
// Switch to keyboard mode on significant axis input
if (event.value.abs() > 0.3) {
onGamepadInput?.call();
_setTraditionalFocusHighlight();
SchedulerBinding.instance.ensureVisualUpdate();
}
switch (event.axis) {
case GamepadAxis.leftStickY:
_handleLeftStickY(event.value);
case GamepadAxis.leftStickX:
_handleLeftStickX(event.value);
default:
break;
}
}
void _moveFocus(TraversalDirection direction) {
// Convert direction to arrow key and simulate a key press
// This allows widgets like HubSection that intercept key events to handle navigation
final logicalKey = _directionToKey(direction);
_logGamepadDiag(
'moveFocus direction=$direction logicalKey=${logicalKey.keyLabel}/${logicalKey.keyId} nativeTextInputFocused=$_nativeTextInputFocused',
);
_simulateKeyPress(logicalKey);
}
/// Fire [direction] immediately, then auto-repeat after an initial delay.
void _startDirectionRepeat(TraversalDirection direction) {
_logGamepadDiag('startDirectionRepeat direction=$direction');
_stopDirectionRepeat();
_moveFocus(direction);
_repeatTimer = Timer(_repeatInitialDelay, () {
_repeatTimer = Timer.periodic(_repeatInterval, (_) {
_moveFocus(direction);
});
});
}
void _stopDirectionRepeat() {
if (_repeatTimer != null) {
_logGamepadDiag('stopDirectionRepeat');
}
_repeatTimer?.cancel();
_repeatTimer = null;
}
LogicalKeyboardKey _directionToKey(TraversalDirection direction) {
switch (direction) {
case TraversalDirection.up:
return LogicalKeyboardKey.arrowUp;
case TraversalDirection.down:
return LogicalKeyboardKey.arrowDown;
case TraversalDirection.left:
return LogicalKeyboardKey.arrowLeft;
case TraversalDirection.right:
return LogicalKeyboardKey.arrowRight;
}
}
/// Simulate a full key press (down + up) in a single frame.
void _simulateKeyPress(LogicalKeyboardKey logicalKey) {
SchedulerBinding.instance.addPostFrameCallback((_) {
_dispatchKeyDown(logicalKey);
_dispatchKeyUp(logicalKey);
});
}
/// Simulate only key down — pair with [_simulateKeyUp] on release
/// so widget-level long-press timers see real hold duration.
void _simulateKeyDown(LogicalKeyboardKey logicalKey) {
SchedulerBinding.instance.addPostFrameCallback((_) {
_dispatchKeyDown(logicalKey);
});
}
/// Simulate only key up — the release half of [_simulateKeyDown].
void _simulateKeyUp(LogicalKeyboardKey logicalKey) {
SchedulerBinding.instance.addPostFrameCallback((_) {
_dispatchKeyUp(logicalKey);
});
}
void _dispatchKeyDown(LogicalKeyboardKey logicalKey) {
_logGamepadDiag('dispatchKeyDown logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
_dispatchKeyEvent(
KeyDownEvent(
physicalKey: _getPhysicalKey(logicalKey),
logicalKey: logicalKey,
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
deviceType: ui.KeyEventDeviceType.gamepad,
),
);
}
void _dispatchKeyUp(LogicalKeyboardKey logicalKey) {
_logGamepadDiag('dispatchKeyUp logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
_dispatchKeyEvent(
KeyUpEvent(
physicalKey: _getPhysicalKey(logicalKey),
logicalKey: logicalKey,
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
deviceType: ui.KeyEventDeviceType.gamepad,
),
);
}
void _dispatchKeyEvent(KeyEvent event) {
FocusNode? node = FocusManager.instance.primaryFocus;
_logGamepadDiag('dispatch start focus=${node?.debugLabel} key=(${_describeGamepadKeyEvent(event)})');
while (node != null) {
if (node.onKeyEvent != null) {
final result = node.onKeyEvent!(node, event);
_logGamepadDiag('dispatch node=${node.debugLabel} result=$result key=(${_describeGamepadKeyEvent(event)})');
if (result != KeyEventResult.ignored) {
_logGamepadDiag('dispatch stopped node=${node.debugLabel} result=$result');
break;
}
}
node = node.parent;
}
if (node == null) {
_logGamepadDiag('dispatch reached root ignored key=(${_describeGamepadKeyEvent(event)})');
}
}
PhysicalKeyboardKey _getPhysicalKey(LogicalKeyboardKey logicalKey) {
if (logicalKey == LogicalKeyboardKey.gameButtonA) {
return PhysicalKeyboardKey.gameButtonA;
} else if (logicalKey == LogicalKeyboardKey.gameButtonB) {
return PhysicalKeyboardKey.gameButtonB;
} else if (logicalKey == LogicalKeyboardKey.gameButtonX) {
return PhysicalKeyboardKey.gameButtonX;
} else if (logicalKey == LogicalKeyboardKey.arrowUp) {
return PhysicalKeyboardKey.arrowUp;
} else if (logicalKey == LogicalKeyboardKey.arrowDown) {
return PhysicalKeyboardKey.arrowDown;
} else if (logicalKey == LogicalKeyboardKey.arrowLeft) {
return PhysicalKeyboardKey.arrowLeft;
} else if (logicalKey == LogicalKeyboardKey.arrowRight) {
return PhysicalKeyboardKey.arrowRight;
} else if (logicalKey == LogicalKeyboardKey.escape) {
return PhysicalKeyboardKey.escape;
}
return PhysicalKeyboardKey.enter;
}
// W3C: leftStickY -1.0 = up, 1.0 = down
void _handleLeftStickY(double value) {
if (value > _stickDeadzone && !_leftStickDown) {
_leftStickDown = true;
_leftStickUp = false;
_startDirectionRepeat(TraversalDirection.down);
} else if (value < -_stickDeadzone && !_leftStickUp) {
_leftStickUp = true;
_leftStickDown = false;
_startDirectionRepeat(TraversalDirection.up);
} else if (value.abs() <= _stickDeadzone) {
if (_leftStickUp || _leftStickDown) _stopDirectionRepeat();
_leftStickUp = false;
_leftStickDown = false;
}
}
void _handleLeftStickX(double value) {
if (value < -_stickDeadzone && !_leftStickLeft) {
_leftStickLeft = true;
_leftStickRight = false;
_startDirectionRepeat(TraversalDirection.left);
} else if (value > _stickDeadzone && !_leftStickRight) {
_leftStickRight = true;
_leftStickLeft = false;
_startDirectionRepeat(TraversalDirection.right);
} else if (value.abs() <= _stickDeadzone) {
if (_leftStickLeft || _leftStickRight) _stopDirectionRepeat();
_leftStickLeft = false;
_leftStickRight = false;
}
}
// Ensure Material uses traditional (keyboard) focus highlights when navigating
// via gamepad. Synthetic key events we dispatch below don't go through the
// platform key pipeline, so Flutter won't automatically flip highlight mode.
void _setTraditionalFocusHighlight() {
if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
}
}
}