import 'dart:async'; import 'dart:io'; import 'dart:ui' as ui; 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 '../focus/input_mode_tracker.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 _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 const LogicalKeyboardKey _rawEnterKey = LogicalKeyboardKey(0x0d); static final Map> _nativeAliasesBySyntheticKey = { LogicalKeyboardKey.arrowUp: {LogicalKeyboardKey.arrowUp}, LogicalKeyboardKey.arrowDown: {LogicalKeyboardKey.arrowDown}, LogicalKeyboardKey.arrowLeft: {LogicalKeyboardKey.arrowLeft}, LogicalKeyboardKey.arrowRight: {LogicalKeyboardKey.arrowRight}, LogicalKeyboardKey.enter: { LogicalKeyboardKey.enter, _rawEnterKey, LogicalKeyboardKey.numpadEnter, LogicalKeyboardKey.select, LogicalKeyboardKey.gameButtonA, }, LogicalKeyboardKey.gameButtonB: { 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, 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); } } @visibleForTesting bool isTvosEngineOwnedGamepadButton({required bool isAppleTV, required GamepadButton button}) { if (!isAppleTV) return false; return switch (button) { GamepadButton.dpadUp || GamepadButton.dpadDown || GamepadButton.dpadLeft || GamepadButton.dpadRight || GamepadButton.a || GamepadButton.b => true, _ => false, }; } /// 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.gameButtonB, GamepadButton.x: LogicalKeyboardKey.gameButtonX, }; static final Map _gamepadPhysicalKeyByLogicalKey = { LogicalKeyboardKey.arrowUp: PhysicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown: PhysicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowLeft: PhysicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight: PhysicalKeyboardKey.arrowRight, LogicalKeyboardKey.enter: PhysicalKeyboardKey.enter, LogicalKeyboardKey.escape: PhysicalKeyboardKey.escape, LogicalKeyboardKey.gameButtonA: PhysicalKeyboardKey.gameButtonA, LogicalKeyboardKey.gameButtonB: PhysicalKeyboardKey.gameButtonB, LogicalKeyboardKey.gameButtonX: PhysicalKeyboardKey.gameButtonX, }; static GamepadService? _instance; StreamSubscription? _subscription; final GamepadDuplicateInputGuard _duplicateInputGuard; static final Map _tabNavigationHandlers = {}; /// Registers owner-scoped bumper navigation. Multiple tab screens can stay /// mounted; only the handler whose screen is currently visible runs. static void registerTabNavigation( Object owner, { required VoidCallback previous, required VoidCallback next, required bool Function() isActive, }) { _tabNavigationHandlers[owner] = (previous: previous, next: next, isActive: isActive); } static void unregisterTabNavigation(Object owner) { _tabNavigationHandlers.remove(owner); } static void _dispatchTabNavigation({required bool previous}) { for (final handler in _tabNavigationHandlers.values) { if (!handler.isActive()) continue; previous ? handler.previous() : handler.next(); return; } } @visibleForTesting static void debugDispatchTabNavigation({required bool previous}) { _dispatchTabNavigation(previous: previous); } @visibleForTesting static void debugClearTabNavigationHandlers() { _tabNavigationHandlers.clear(); } // 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); key_sim.KeyEventSimulatorController? _keyEventSimulator; // 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 _pressedButtons = {}; final Set _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 Function(bool focused)? debugNativeTextInputFocusHandler; GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard}) : _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows); key_sim.KeyEventSimulatorController get _simulator { return _keyEventSimulator ??= key_sim.KeyEventSimulatorController( deviceType: ui.KeyEventDeviceType.gamepad, physicalKeyByLogicalKey: _gamepadPhysicalKeyByLogicalKey, log: _logGamepadDiag, ); } static GamepadService get instance { _instance ??= GamepadService._(); return _instance!; } static Future 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(); _keyEventSimulator?.dispose(); _keyEventSimulator = null; if (_isDesktop) { windowManager.removeListener(this); } Gamepad.instance.dispose(); } @override void onWindowFocus() { _windowFocused = true; _duplicateInputGuard.clear(); Gamepad.instance.resume(); } @override void onWindowBlur() { _windowFocused = false; _stopDirectionRepeat(); // Release all face buttons in one frame so held widget state cannot stick. _simulator.releaseKeys([ if (_pressedButtons.contains(GamepadButton.a)) LogicalKeyboardKey.enter, if (_pressedButtons.contains(GamepadButton.x)) 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 _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(); _keyEventSimulator?.clearHeldKeys(); _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; } if (isTvosEngineOwnedGamepadButton(isAppleTV: PlatformDetector.isAppleTV(), button: event.button)) { _logGamepadDiag('button ignored because tvOS engine owns its key lifecycle ${_describeGamepadButton(event)}'); return; } // Switch to keyboard mode on any button press if (event.pressed) { InputModeTracker.reportNonPointerInput(); } // 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 back ${_describeGamepadButton(event)}'); _simulateKeyPress(LogicalKeyboardKey.gameButtonB); case GamepadButton.leftShoulder: _dispatchTabNavigation(previous: true); case GamepadButton.rightShoulder: _dispatchTabNavigation(previous: false); 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; } // Promotion must fire on the same event that navigates: gating below the // real deadzone would let analog-stick drift hide the desktop cursor. if (event.value.abs() > _stickDeadzone) { InputModeTracker.reportNonPointerInput(); } switch (event.axis) { case GamepadAxis.leftStickY: _handleLeftStickY(event.value); case GamepadAxis.leftStickX: _handleLeftStickX(event.value); default: break; } } /// Fire [direction] immediately, then auto-repeat after an initial delay. void _startDirectionRepeat(TraversalDirection direction) { _logGamepadDiag('startDirectionRepeat direction=$direction'); _stopDirectionRepeat(); final logicalKey = _directionToKey(direction); _logGamepadDiag( 'moveFocus direction=$direction logicalKey=${logicalKey.keyLabel}/${logicalKey.keyId} nativeTextInputFocused=$_nativeTextInputFocused', ); _simulator.startKeyRepeat(logicalKey, initialDelay: _repeatInitialDelay, interval: _repeatInterval); } void _stopDirectionRepeat() { if (_keyEventSimulator?.isRepeating ?? false) { _logGamepadDiag('stopDirectionRepeat'); } _keyEventSimulator?.stopKeyRepeat(); } 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) { _simulator.simulateKeyPress(logicalKey); } /// Simulate only key down — pair with [_simulateKeyUp] on release /// so widget-level long-press timers see real hold duration. void _simulateKeyDown(LogicalKeyboardKey logicalKey) { _simulator.simulateKeyDown(logicalKey); } /// Simulate only key up — the release half of [_simulateKeyDown]. void _simulateKeyUp(LogicalKeyboardKey logicalKey) { _simulator.simulateKeyUp(logicalKey); } // 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; } } }