fix: suppress stray back KeyUp globally via NavigatorObserver

This commit is contained in:
edde746
2026-02-08 11:57:19 +01:00
parent 2a1c99ac06
commit e6fef26e8e
7 changed files with 51 additions and 21 deletions
+29
View File
@@ -121,3 +121,32 @@ class BackKeyUpSuppressor {
return false;
}
}
/// Tracks whether a back key is currently physically pressed.
///
/// Used by [BackKeySuppressorObserver] to detect when a route pop was
/// caused by a back key press (e.g. Flutter's built-in DismissAction,
/// DismissAction on KeyRepeat, or Android TV system back gesture) so it
/// can automatically suppress the stray KeyUp that follows.
class BackKeyPressTracker {
static bool _isBackKeyDown = false;
/// Whether a back key is currently held down.
///
/// Also checks [HardwareKeyboard.instance.logicalKeysPressed] as a
/// fallback in case our tracking drifted out of sync.
static bool get isBackKeyDown {
if (_isBackKeyDown) return true;
return HardwareKeyboard.instance.logicalKeysPressed.any(
(key) => key.isBackKey,
);
}
static bool handleKeyEvent(KeyEvent event) {
if (event.logicalKey.isBackKey) {
// KeyDown and KeyRepeat both mean the key is physically held.
_isBackKeyDown = event is! KeyUpEvent;
}
return false; // Never consume
}
}
+5
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import '../services/gamepad_service.dart';
import 'dpad_navigator.dart';
/// Tracks whether the user is navigating via keyboard/d-pad or pointer (mouse/touch).
///
@@ -66,6 +67,10 @@ class _InputModeTrackerState extends State<InputModeTracker> {
}
bool _handleKeyEvent(KeyEvent event) {
// Track back key press state for automatic suppression of stray KeyUp
// events after route pops (see BackKeySuppressorObserver).
BackKeyPressTracker.handleKeyEvent(event);
// Only switch to keyboard mode on key down (not repeats or releases)
if (event is KeyDownEvent) {
_setMode(InputMode.keyboard);
+15
View File
@@ -82,3 +82,18 @@ KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event,
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
/// Navigator observer that automatically suppresses stray back KeyUp events
/// after any route pop caused by a back key press.
///
/// This catches pops triggered by Flutter's built-in DismissAction (which fires
/// on KeyDown for dialogs) and Android TV system back gestures, preventing the
/// orphaned KeyUp from propagating to the underlying screen's back handler.
class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
}
}
+2 -1
View File
@@ -42,6 +42,7 @@ import 'utils/orientation_helper.dart';
import 'utils/language_codes.dart';
import 'i18n/strings.g.dart';
import 'focus/input_mode_tracker.dart';
import 'focus/key_event_utils.dart';
import 'package:intl/date_symbol_data_local.dart';
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
@@ -317,7 +318,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
theme: themeProvider.lightTheme,
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver],
navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
home: const OrientationAwareSetup(),
),
),
-7
View File
@@ -1155,7 +1155,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
if (!mounted) return;
_isExiting.value = true;
BackKeyUpSuppressor.suppressBackUntilKeyUp();
Navigator.of(context).pop(true);
}
}
@@ -1176,12 +1175,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Default behavior for hosts or non-session users
if (!mounted) return;
_isExiting.value = true;
// Suppress stray BACK KeyUp on the previous route: on Android TV the
// system back gesture pops the route around KeyDown, so the KeyUp arrives
// at the previous route and would be misinterpreted as a new BACK press.
// markClosedViaBackKey() (set by handleBackKeyAction for key-triggered
// pops) makes this a no-op, so it only activates for system-back pops.
BackKeyUpSuppressor.suppressBackUntilKeyUp();
Navigator.of(context).pop(true);
} finally {
_isHandlingBack = false;
-11
View File
@@ -278,8 +278,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
focusFirstItem: openedFromKeyboard,
),
);
// Suppress BACK key-up to prevent it from propagating to the parent screen
BackKeyUpSuppressor.suppressBackUntilKeyUp();
} else {
// Show custom focusable popup menu on larger screens
// Use stored tap position or fallback to widget position
@@ -299,8 +297,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
builder: (dialogContext) =>
_FocusablePopupMenu(actions: menuActions, position: position, focusFirstItem: openedFromKeyboard),
);
// Suppress BACK key-up to prevent it from propagating to the parent screen
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
try {
@@ -518,7 +514,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
backgroundColor: Colors.transparent,
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
);
BackKeyUpSuppressor.suppressBackUntilKeyUp();
} else if (context.mounted) {
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
}
@@ -585,7 +580,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
),
),
);
BackKeyUpSuppressor.suppressBackUntilKeyUp();
} else {
// Show popup menu on desktop
selected = await showMenu<String>(
@@ -606,7 +600,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
);
}).toList(),
);
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
// Handle the submenu selection
@@ -635,7 +628,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
context: context,
builder: (context) => _PlaylistSelectionDialog(playlists: playlists),
);
BackKeyUpSuppressor.suppressBackUntilKeyUp();
if (result == null || !context.mounted) return;
@@ -654,7 +646,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
labelText: t.playlists.playlistName,
hintText: t.playlists.enterPlaylistName,
);
BackKeyUpSuppressor.suppressBackUntilKeyUp();
if (playlistName == null || playlistName.isEmpty || !context.mounted) {
return;
@@ -773,7 +764,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
context: context,
builder: (context) => _CollectionSelectionDialog(collections: collections),
);
BackKeyUpSuppressor.suppressBackUntilKeyUp();
if (result == null || !context.mounted) return;
@@ -791,7 +781,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
labelText: t.collections.collectionName,
hintText: t.collections.enterCollectionName,
);
BackKeyUpSuppressor.suppressBackUntilKeyUp();
if (collectionName == null || collectionName.isEmpty || !context.mounted) {
return;
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'video_sheet_header.dart';
/// Base class for video control bottom sheets providing common UI structure
@@ -46,7 +45,6 @@ class BaseVideoControlSheet extends StatelessWidget {
constraints: getBottomSheetConstraints(context),
builder: builder,
).whenComplete(() {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
onClose?.call();
});
}