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.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/key_event_simulator.dart' as key_sim;
|
||||
import 'gamepad_service.dart';
|
||||
@@ -31,6 +31,10 @@ class AppleTvRemoteTouchService {
|
||||
final VoidCallback _scheduleFrame;
|
||||
final DateTime Function() _now;
|
||||
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
||||
|
||||
/// Announces that a pointerless device produced input. Injected so tests can
|
||||
/// observe it without a widget tree; defaults to the app-wide tracker.
|
||||
final void Function() reportNonPointerInput;
|
||||
final StreamController<AppleTvRemotePlayPauseAction> _playPauseController =
|
||||
StreamController<AppleTvRemotePlayPauseAction>.broadcast();
|
||||
final double swipeThreshold;
|
||||
@@ -53,6 +57,7 @@ class AppleTvRemoteTouchService {
|
||||
VoidCallback? scheduleFrame,
|
||||
DateTime Function()? now,
|
||||
GamepadDuplicateInputGuard? duplicateInputGuard,
|
||||
this.reportNonPointerInput = InputModeTracker.reportNonPointerInput,
|
||||
Duration duplicateSuppressionWindow = GamepadDuplicateInputGuard.defaultSuppressionWindow,
|
||||
this.swipeThreshold = defaultSwipeThreshold,
|
||||
this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio,
|
||||
@@ -229,7 +234,7 @@ class AppleTvRemoteTouchService {
|
||||
return false;
|
||||
}
|
||||
|
||||
_setTraditionalFocusHighlight();
|
||||
reportNonPointerInput();
|
||||
_scheduleFrame();
|
||||
_log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}');
|
||||
_simulateKeyPress(logicalKey);
|
||||
@@ -256,12 +261,6 @@ class AppleTvRemoteTouchService {
|
||||
_nativeKeyHandlerRegistered = false;
|
||||
}
|
||||
|
||||
void _setTraditionalFocusHighlight() {
|
||||
if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
}
|
||||
}
|
||||
|
||||
void _logTouch(String type, Map<dynamic, dynamic> arguments) {
|
||||
final x = _toDouble(arguments['x']);
|
||||
final y = _toDouble(arguments['y']);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/key_event_simulator.dart';
|
||||
@@ -15,10 +16,6 @@ class CompanionRemoteReceiver {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// Called on any remote input so InputModeTracker can switch to keyboard mode.
|
||||
/// Same pattern as [GamepadService.onGamepadInput].
|
||||
static VoidCallback? onRemoteInput;
|
||||
|
||||
/// Owners prevent a disposed screen from clearing callbacks installed by a
|
||||
/// replacement screen later in the same frame.
|
||||
Object? navigationOwner;
|
||||
@@ -49,10 +46,14 @@ class CompanionRemoteReceiver {
|
||||
void handleCommand(RemoteCommand command, BuildContext? _) {
|
||||
appLogger.d('CompanionRemoteReceiver: Handling command: ${command.type}');
|
||||
|
||||
// Switch to keyboard mode so focus visuals render
|
||||
onRemoteInput?.call();
|
||||
_setTraditionalFocusHighlight();
|
||||
scheduleFrameIfIdle();
|
||||
// A paired phone cannot point, so any viewer command is evidence of a
|
||||
// pointerless device. Protocol frames are not viewer input: promoting on the
|
||||
// periodic ping would flip an idle desktop host into keyboard mode — and hide
|
||||
// its cursor — on every heartbeat.
|
||||
if (_isViewerInput(command.type)) {
|
||||
InputModeTracker.reportNonPointerInput();
|
||||
scheduleFrameIfIdle();
|
||||
}
|
||||
|
||||
switch (command.type) {
|
||||
case RemoteCommandType.dpadUp:
|
||||
@@ -140,10 +141,49 @@ class CompanionRemoteReceiver {
|
||||
appLogger.w('CompanionRemoteReceiver: Unhandled command type: ${command.type}');
|
||||
}
|
||||
}
|
||||
|
||||
void _setTraditionalFocusHighlight() {
|
||||
if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exhaustive by design: no default clause, so adding a [RemoteCommandType]
|
||||
/// is a compile error until someone decides whether it counts as viewer input.
|
||||
bool _isViewerInput(RemoteCommandType type) => switch (type) {
|
||||
RemoteCommandType.ping ||
|
||||
RemoteCommandType.pong ||
|
||||
RemoteCommandType.ack ||
|
||||
RemoteCommandType.deviceInfo ||
|
||||
RemoteCommandType.disconnect ||
|
||||
RemoteCommandType.syncState => false,
|
||||
RemoteCommandType.dpadUp ||
|
||||
RemoteCommandType.dpadDown ||
|
||||
RemoteCommandType.dpadLeft ||
|
||||
RemoteCommandType.dpadRight ||
|
||||
RemoteCommandType.select ||
|
||||
RemoteCommandType.back ||
|
||||
RemoteCommandType.contextMenu ||
|
||||
RemoteCommandType.play ||
|
||||
RemoteCommandType.pause ||
|
||||
RemoteCommandType.playPause ||
|
||||
RemoteCommandType.stop ||
|
||||
RemoteCommandType.seekForward ||
|
||||
RemoteCommandType.seekBackward ||
|
||||
RemoteCommandType.nextTrack ||
|
||||
RemoteCommandType.previousTrack ||
|
||||
RemoteCommandType.skipIntro ||
|
||||
RemoteCommandType.skipCredits ||
|
||||
RemoteCommandType.volumeUp ||
|
||||
RemoteCommandType.volumeDown ||
|
||||
RemoteCommandType.volumeMute ||
|
||||
RemoteCommandType.volumeSet ||
|
||||
RemoteCommandType.tabNext ||
|
||||
RemoteCommandType.tabPrevious ||
|
||||
RemoteCommandType.tabDiscover ||
|
||||
RemoteCommandType.tabLibraries ||
|
||||
RemoteCommandType.tabSearch ||
|
||||
RemoteCommandType.tabDownloads ||
|
||||
RemoteCommandType.tabSettings ||
|
||||
RemoteCommandType.home ||
|
||||
RemoteCommandType.search ||
|
||||
RemoteCommandType.subtitles ||
|
||||
RemoteCommandType.audioTracks ||
|
||||
RemoteCommandType.qualitySettings ||
|
||||
RemoteCommandType.fullscreen => true,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -155,10 +156,6 @@ class GamepadService with WindowListener {
|
||||
StreamSubscription<GamepadEvent>? _subscription;
|
||||
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
||||
|
||||
/// Callback to switch InputModeTracker to keyboard mode.
|
||||
/// Set by InputModeTracker when it initializes.
|
||||
static VoidCallback? onGamepadInput;
|
||||
|
||||
static final Map<Object, ({VoidCallback previous, VoidCallback next, bool Function() isActive})>
|
||||
_tabNavigationHandlers = {};
|
||||
|
||||
@@ -403,8 +400,7 @@ class GamepadService with WindowListener {
|
||||
|
||||
// Switch to keyboard mode on any button press
|
||||
if (event.pressed) {
|
||||
onGamepadInput?.call();
|
||||
_setTraditionalFocusHighlight();
|
||||
InputModeTracker.reportNonPointerInput();
|
||||
}
|
||||
// Ensure a frame is scheduled so addPostFrameCallback-based key
|
||||
// simulation fires promptly. Without this, key-up events can be
|
||||
@@ -503,11 +499,10 @@ class GamepadService with WindowListener {
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to keyboard mode on significant axis input. Navigation itself
|
||||
// schedules frames only when the stick crosses the real deadzone.
|
||||
if (event.value.abs() > 0.3) {
|
||||
onGamepadInput?.call();
|
||||
_setTraditionalFocusHighlight();
|
||||
// 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) {
|
||||
@@ -599,13 +594,4 @@ class GamepadService with WindowListener {
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user