fix: suppress stray back KeyUp globally via NavigatorObserver
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user