Files
plezy/lib/services/gamepad_service.dart
T
edde746 e3703892b3 fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and
dropped focus onto Play/Pause, even with Video Player Navigation off. Two
independent paths did it. InputModeTracker promoted on any key satisfying
isNavigationKey, a set that unioned activation, dismissal and the menu key
with the arrows and consulted no setting at all; separately the surface's
Select handler always asked the chrome for focus. Escape had the same effect,
which on desktop reads as the mouse cursor vanishing mid-playback.

Both now ask one predicate. eventRequestsFocusNavigation decides whether the
app switches to keyboard mode and whether a key may hand focus to the chrome,
so the two cannot disagree and focus can never land on a control while focus
chrome is still suppressed. Activation and dismissal act on what already has
focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an
arrow that will really traverse answer yes. The one input the predicate cannot
read off the event, whether the focused feature owns arrow keys, rides on the
node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet,
prompt and OSD button stays an ordinary traversal target with nothing to
re-enable.

playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace
five hand-copied pref-or-isTV expressions and a screen-level cache that
disagreed with the live getter after a toggle. Services whose input is
synthesized past HardwareKeyboard announce themselves through
InputModeTracker.reportNonPointerInput rather than two static callbacks and
three copies of a highlight-strategy write. That registration is now
identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker
after the incoming one initialised and cleared both callbacks, so gamepad and
companion remote input had stopped switching to keyboard mode entirely.

Falling out of the same rule: a companion heartbeat no longer flips an idle
desktop host into keyboard mode, analog-stick drift promotes only past the
deadzone that actually navigates, Enter keeps toggling playback once the
chrome is up, Tab both reaches and traverses the OSD, and the player surface
claims the remote from mount rather than only when the chrome starts hidden,
so the first key on a desktop route is a playback shortcut instead of the
screen node's chrome-raising self-heal.

isNavigationKey becomes isReservedControlKey, since its real meaning is a
shell key rather than a text character and the old name is what invited the
conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
2026-08-07 13:23:53 +02:00

598 lines
22 KiB
Dart

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<LogicalKeyboardKey, Set<LogicalKeyboardKey>> _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<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);
}
}
@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<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.gameButtonB,
GamepadButton.x: LogicalKeyboardKey.gameButtonX,
};
static final Map<LogicalKeyboardKey, PhysicalKeyboardKey> _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<GamepadEvent>? _subscription;
final GamepadDuplicateInputGuard _duplicateInputGuard;
static final Map<Object, ({VoidCallback previous, VoidCallback next, bool Function() isActive})>
_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<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);
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<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();
_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<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();
_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;
}
}
}