diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index 1271e5bd..4760c46d 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -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 + } +} diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart index bf311f48..8462b912 100644 --- a/lib/focus/input_mode_tracker.dart +++ b/lib/focus/input_mode_tracker.dart @@ -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 { } 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); diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index 70b808e9..f2370b35 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -82,3 +82,18 @@ KeyEventResult handleBackKeyNavigation(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(); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index a244b67e..154a8a39 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 with WidgetsBindingObserver { theme: themeProvider.lightTheme, darkTheme: themeProvider.darkTheme, themeMode: themeProvider.materialThemeMode, - navigatorObservers: [routeObserver], + navigatorObservers: [routeObserver, BackKeySuppressorObserver()], home: const OrientationAwareSetup(), ), ), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 36df6e8c..68e70675 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1155,7 +1155,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } if (!mounted) return; _isExiting.value = true; - BackKeyUpSuppressor.suppressBackUntilKeyUp(); Navigator.of(context).pop(true); } } @@ -1176,12 +1175,6 @@ class VideoPlayerScreenState extends State 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; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 61f248ab..f5a5130c 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -278,8 +278,6 @@ class MediaContextMenuState extends State { 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 { 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 { 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 { ), ), ); - BackKeyUpSuppressor.suppressBackUntilKeyUp(); } else { // Show popup menu on desktop selected = await showMenu( @@ -606,7 +600,6 @@ class MediaContextMenuState extends State { ); }).toList(), ); - BackKeyUpSuppressor.suppressBackUntilKeyUp(); } // Handle the submenu selection @@ -635,7 +628,6 @@ class MediaContextMenuState extends State { context: context, builder: (context) => _PlaylistSelectionDialog(playlists: playlists), ); - BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (result == null || !context.mounted) return; @@ -654,7 +646,6 @@ class MediaContextMenuState extends State { 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 { context: context, builder: (context) => _CollectionSelectionDialog(collections: collections), ); - BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (result == null || !context.mounted) return; @@ -791,7 +781,6 @@ class MediaContextMenuState extends State { labelText: t.collections.collectionName, hintText: t.collections.enterCollectionName, ); - BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (collectionName == null || collectionName.isEmpty || !context.mounted) { return; diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart index 63223ff6..44e58819 100644 --- a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -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(); }); }