Files
plezy/lib/focus/focus_navigation_intent.dart
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

86 lines
4.1 KiB
Dart

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// A [FocusNode] whose owner consumes arrow / D-pad keys itself instead of
/// letting them traverse focus — the video player seeks with them.
///
/// Focus-mode detection needs this fact: an arrow pressed while such a node
/// holds focus is not evidence that the viewer wants to navigate by focus, so
/// it must not switch the app into keyboard mode and light up focus chrome
/// everywhere.
///
/// The fact rides on the node rather than on a subtree because it depends on
/// *what has focus*, not on where a widget sits. Every ordinary control, sheet,
/// prompt and OSD button therefore stays a plain [FocusNode] and keeps working.
///
/// Declare a node only when it can hold primary focus **without the viewer
/// having navigated to it** — autofocus, a focus reclaim, a self-heal.
/// Everything reached by traversal is already in keyboard mode, so the marker
/// would be redundant there: sliders, spinners, the TV keyboard and the OSD's
/// own buttons all consume arrows and all correctly stay plain nodes.
class DirectionalShortcutFocusNode extends FocusNode {
DirectionalShortcutFocusNode({required this.consumesDirectionalKeys, super.debugLabel, super.skipTraversal});
/// Evaluated per key press, so live settings and chrome state need no
/// syncing. Takes the key because a feature can own one axis and not the
/// other — the player seeks with Left/Right while Up/Down raises the chrome.
final bool Function(LogicalKeyboardKey key) consumesDirectionalKeys;
static bool ownsDirectionalKeys(FocusNode? node, LogicalKeyboardKey key) =>
node is DirectionalShortcutFocusNode && node.consumesDirectionalKeys(key);
}
/// Whether [event] is evidence that the viewer wants to navigate by focus.
///
/// This is the single answer to two questions that must never disagree:
/// whether [InputModeTracker] switches to keyboard mode, and whether a key may
/// hand focus to the video player's chrome. Deciding them separately is what
/// produced focus appearing on a control while the app still believed a pointer
/// was driving — focus chrome is mode-gated, so the viewer got an invisible
/// selection.
///
/// Activation (Enter) and dismissal (Escape) from a physical keyboard are *not*
/// navigation: they act on whatever already has focus. Promoting on them arms
/// focus chrome — and hides the desktop cursor — for a viewer who only pressed
/// play.
///
/// [focused] is the node that will receive the event; it defaults to the
/// primary focus. Handlers inside a `Focus.onKeyEvent` pass their own node
/// instead of re-reading the global.
bool eventRequestsFocusNavigation(KeyEvent event, {FocusNode? focused}) {
if (event is! KeyDownEvent) return false;
final key = event.logicalKey;
// Unambiguous traversal on every platform.
if (key == LogicalKeyboardKey.tab) return true;
// Opens a menu that takes focus into a new scope, so it does start a session.
if (key.isContextMenuKey) return true;
// `select` / `gameButtonA` are remote-only whatever the engine claims about
// deviceType; `enter` counts from a non-keyboard device, or on TV where a
// remote's OK legitimately arrives as a keyboard `enter` (the same shape
// pin_entry_dialog.dart already compensates for).
if (key.isSelectKey) {
return event.isTvSelectEvent || (PlatformDetector.isTV() && event.isPhysicalKeyboardEnter);
}
// Remote BACK proves a pointerless device. A physical keyboard's `escape` and
// a media keyboard's `browserBack` only dismiss, so they stay out — matching
// how classifyPlayerNavigationKey already reads a non-keyboard `escape`.
if (key.isBackKey) {
return key == LogicalKeyboardKey.goBack || key == LogicalKeyboardKey.gameButtonB || !event.isPhysicalKeyboardEvent;
}
// Arrows are navigation only where they are not the focused feature's own
// shortcut.
if (key.isDpadDirection) {
return !DirectionalShortcutFocusNode.ownsDirectionalKeys(focused ?? FocusManager.instance.primaryFocus, key);
}
return false;
}