fix(player): keep Delete and Home editing text in player sheets

Bare Backspace and Home are player navigation keys, but they are also
caret editing keys. The player screen's Focus wraps its OverlaySheetHost,
so it saw them before the subtitle-search field could act: the press was
consumed on key-down, DefaultTextEditingShortcuts never turned it into a
deletion, and the back pipeline hid the chrome and then left the player.

A focused text editor now takes both keys back, but only for physical
keyboard presses — a synthesized dpad/gamepad press has no caret, and
browserHome has no editing role at all.

The screen also resolved its overlay-sheet controller from the State's
own context, which sits above the host it was querying, so the lookup
always returned null and Back skipped the sheet stage entirely. Resolve
it from a context below the host instead, matching NowPlayingScreen.

close #1741
This commit is contained in:
edde746
2026-08-02 07:37:12 +02:00
parent 2a7e5f4f9c
commit bac2a0d201
5 changed files with 421 additions and 3 deletions
+17
View File
@@ -123,6 +123,23 @@ KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
return KeyEventResult.handled;
}
/// Whether the primary focus currently belongs to an active text editor.
///
/// Ancestor key handlers use this to stay off keys a focused field owns.
/// Flutter dispatches a key event from the focused node upwards, and the
/// editing shortcuts that turn Backspace into a deletion live in
/// [DefaultTextEditingShortcuts] at the very top of the app — *above* any
/// screen. An ancestor that claims Backspace as "back" therefore both steals
/// the navigation and stops the character from ever being deleted (#1741).
///
/// [EditableText] builds its [Focus] internally, so the focused node's context
/// resolves to the owning [EditableTextState].
bool isTextEditingFocused() {
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return false;
return context.findAncestorStateOfType<EditableTextState>() != null;
}
/// Expands a UTF-16 [range] to whole extended grapheme clusters in [text].
///
/// Flutter selections use UTF-16 code-unit offsets. Custom editors must pass a
+10 -1
View File
@@ -466,6 +466,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// key events never escape the video player route.
late final FocusNode _screenFocusNode;
/// Key for a context below this screen's own [OverlaySheetHost]. The State's
/// context sits ABOVE the host, so resolving the controller with `context`
/// always misses it and the screen would walk its back pipeline (hide chrome,
/// then exit the player) while a sheet is still open (#1741).
final GlobalKey _overlayChildKey = GlobalKey();
BuildContext get _sheetContext => _overlayChildKey.currentContext ?? context;
// VLC-style in-player toast controller (rate changes, backend switch, etc.).
final PlayerToastController _toastController = PlayerToastController();
bool _reclaimingFocus = false;
@@ -1454,7 +1462,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _handleScreenPlayerNavigation(PlayerNavigationKey navigationKey) {
if (navigationKey != PlayerNavigationKey.home) {
final sheetController = OverlaySheetController.maybeOf(context);
final sheetController = OverlaySheetController.maybeOf(_sheetContext);
if (sheetController?.isOpen ?? false) {
sheetController!.pop();
return;
@@ -1998,6 +2006,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_handleScreenPlayerNavigation(PlayerNavigationKey.back);
},
child: Builder(
key: _overlayChildKey,
builder: (sheetContext) => _isPlayerInitialized && player != null
? _buildVideoPlayer(sheetContext)
: (_playerInitializationError != null
+21 -2
View File
@@ -325,7 +325,22 @@ PlayerBackDisposition resolvePlayerBackDisposition({
return controlsVisible ? PlayerBackDisposition.hideControls : PlayerBackDisposition.exitPlayer;
}
PlayerNavigationKey classifyPlayerNavigationKey(KeyEvent event, {required bool isAppleTV, bool? hasModifiers}) {
/// Maps a key event to the player-level navigation stage it should drive.
///
/// [textEditingActive] defaults to [isTextEditingFocused]; inject it in tests.
/// Bare Backspace and Home double as player navigation *and* as caret editing
/// keys, so a focused text editor takes them back — otherwise typing in a
/// player sheet (subtitle search) walks the back pipeline out of the player
/// instead of correcting a character (#1741). Only physical-keyboard presses
/// are surrendered: a synthesized dpad/gamepad/companion press has no caret,
/// and [LogicalKeyboardKey.browserHome] is a dedicated navigation key with no
/// editing role at all.
PlayerNavigationKey classifyPlayerNavigationKey(
KeyEvent event, {
required bool isAppleTV,
bool? hasModifiers,
bool? textEditingActive,
}) {
final key = event.logicalKey;
if (key == LogicalKeyboardKey.escape) {
return event.isPhysicalKeyboardEvent && !isAppleTV ? PlayerNavigationKey.physicalEscape : PlayerNavigationKey.back;
@@ -338,10 +353,14 @@ PlayerNavigationKey classifyPlayerNavigationKey(KeyEvent event, {required bool i
HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isAltPressed ||
HardwareKeyboard.instance.isMetaPressed);
// Resolved lazily: only the two editing keys below pay for the focus lookup.
bool textEditorOwnsKey() => event.isPhysicalKeyboardEvent && (textEditingActive ?? isTextEditingFocused());
if (key == LogicalKeyboardKey.backspace && event.isPhysicalKeyboardEvent && !modifiersPressed) {
return PlayerNavigationKey.back;
return textEditorOwnsKey() ? PlayerNavigationKey.none : PlayerNavigationKey.back;
}
if ((key == LogicalKeyboardKey.home || key == LogicalKeyboardKey.browserHome) && !modifiersPressed) {
if (key == LogicalKeyboardKey.home && textEditorOwnsKey()) return PlayerNavigationKey.none;
return PlayerNavigationKey.home;
}
return PlayerNavigationKey.none;