diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index 0a57e2d0..ab68b3c4 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'dpad_navigator.dart'; +import 'key_event_utils.dart'; /// Callbacks for chip key event handling. class ChipKeyCallbacks { @@ -102,12 +103,19 @@ mixin FocusableChipStateMixin on State { /// Returns [KeyEventResult.handled] if the event was consumed, /// [KeyEventResult.ignored] otherwise. KeyEventResult handleChipKeyEvent(FocusNode node, KeyEvent event, ChipKeyCallbacks callbacks) { + final key = event.logicalKey; + + if (callbacks.onBack != null) { + final backResult = handleBackKeyAction(event, callbacks.onBack!); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + } + if (!event.isActionable) { return KeyEventResult.ignored; } - final key = event.logicalKey; - // SELECT key activates the chip if (key.isSelectKey && callbacks.onSelect != null) { callbacks.onSelect!(); @@ -138,12 +146,6 @@ mixin FocusableChipStateMixin on State { return KeyEventResult.handled; } - // BACK key - if (key.isBackKey && callbacks.onBack != null) { - callbacks.onBack!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; } } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 13d22bf3..ade56dbb 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'dpad_navigator.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; +import 'key_event_utils.dart'; /// A wrapper widget that makes its child focusable with D-pad navigation support. /// @@ -300,6 +301,13 @@ class _FocusableWrapperState extends State with SingleTickerPr } } + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + } + // Handle SELECT key with optional long-press detection if (key.isSelectKey) { if (widget.enableLongPress) { @@ -358,12 +366,6 @@ class _FocusableWrapperState extends State with SingleTickerPr return KeyEventResult.handled; } - // BACK key - if (key.isBackKey && widget.onBack != null) { - widget.onBack!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; } diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index 3bc303b9..17d63c27 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -28,16 +28,53 @@ import 'dpad_navigator.dart'; /// child: ... /// ) /// ``` -KeyEventResult handleBackKeyNavigation(BuildContext context, KeyEvent event, {T? result}) { - // Handle on KeyUpEvent to prevent double-pop when returning from child screens - // (KeyDownEvent can be received by both the popping screen and the returned-to screen) - if (event is KeyUpEvent && event.logicalKey.isBackKey) { - Navigator.pop(context, result); +class BackKeyCoordinator { + static bool _handledThisFrame = false; + static bool _clearScheduled = false; + + static void markHandled() { + _handledThisFrame = true; + if (_clearScheduled) return; + _clearScheduled = true; + // Clear on next frame to avoid blocking unrelated future back presses. + WidgetsBinding.instance.addPostFrameCallback((_) { + _handledThisFrame = false; + _clearScheduled = false; + }); + } + + static bool consumeIfHandled() { + if (_handledThisFrame) { + _handledThisFrame = false; + return true; + } + return false; + } +} + +/// Handle a BACK key press by running [onBack] on key up. +/// +/// This consumes KeyDown/KeyRepeat to avoid duplicate actions from key repeat. +/// Optionally suppresses stray KeyUp events delivered to the next route after a pop. +KeyEventResult handleBackKeyAction( + KeyEvent event, + VoidCallback onBack, +) { + if (!event.logicalKey.isBackKey) return KeyEventResult.ignored; + + if (event is KeyUpEvent) { + BackKeyCoordinator.markHandled(); + onBack(); return KeyEventResult.handled; } - // Consume KeyDownEvent to prevent it from propagating but don't pop yet - if (event is KeyDownEvent && event.logicalKey.isBackKey) { + if (event is KeyDownEvent || event is KeyRepeatEvent) { return KeyEventResult.handled; } return KeyEventResult.ignored; } + +KeyEventResult handleBackKeyNavigation(BuildContext context, KeyEvent event, {T? result}) { + // Handle on KeyUpEvent to prevent double-pop when returning from child screens + // (KeyDownEvent can be received by both the popping screen and the returned-to screen) + return handleBackKeyAction(event, () => Navigator.pop(context, result)); +} diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 2d966ba7..f60df567 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -13,6 +13,7 @@ import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; +import '../focus/key_event_utils.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { @@ -135,6 +136,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen on State { /// Handle key events when app bar is focused KeyEventResult handleAppBarKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - final key = event.logicalKey; final maxButton = appBarButtonCount - 1; + final backResult = handleBackKeyAction(event, () => Navigator.pop(context)); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (key.isLeftKey && appBarFocusedButton > 0) { setState(() => appBarFocusedButton--); _focusAppBarButton(appBarFocusedButton); @@ -168,11 +174,6 @@ mixin FocusableDetailScreenMixin on State { } return KeyEventResult.handled; } - if (key.isBackKey) { - // Already on app bar, exit the screen - Navigator.pop(context); - return KeyEventResult.handled; - } return KeyEventResult.ignored; } diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index e623dcca..1e8b8e42 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/input_mode_tracker.dart'; +import '../../focus/key_event_utils.dart'; import '../../services/gamepad_service.dart'; import '../../../services/plex_client.dart'; import '../../models/plex_library.dart'; @@ -1223,10 +1224,30 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - final key = event.logicalKey; + final backResult = handleBackKeyAction(event, () { + if (_movingIndex != null) { + // Cancel move - restore original position + setState(() { + if (_originalOrder != null) { + _tempLibraries = List.from(_originalOrder!); + } + _focusedIndex = _originalIndex ?? 0; + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + } else { + Navigator.pop(context); + } + }); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (_movingIndex != null) { // Move mode - arrows reorder the item if (key.isUpKey && _movingIndex! > 0) { @@ -1257,19 +1278,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { }); return KeyEventResult.handled; } - if (key.isBackKey) { - // Cancel move - restore original position - setState(() { - if (_originalOrder != null) { - _tempLibraries = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - return KeyEventResult.handled; - } } else { // Navigation mode if (key.isUpKey && _focusedIndex > 0) { @@ -1315,10 +1323,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { } return KeyEventResult.handled; } - if (key.isBackKey) { - Navigator.pop(context); - return KeyEventResult.handled; - } } return KeyEventResult.ignored; diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 321b7ea7..4136f01b 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -27,6 +27,7 @@ import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; import '../utils/desktop_window_padding.dart'; import '../widgets/side_navigation_rail.dart'; +import '../focus/key_event_utils.dart'; import 'discover_screen.dart'; import 'libraries/libraries_screen.dart'; import 'search_screen.dart'; @@ -419,25 +420,14 @@ class _MainScreenState extends State with RouteAware, WindowListener } KeyEventResult _handleBackKey(KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - - // Handle all back keys - this handler is only reached if lower widgets - // (e.g., LibrariesScreen tab content/chips) don't handle the back key first - final isBackKey = - event.logicalKey == LogicalKeyboardKey.escape || - event.logicalKey == LogicalKeyboardKey.goBack || - event.logicalKey == LogicalKeyboardKey.browserBack || - event.logicalKey == LogicalKeyboardKey.gameButtonB; - - if (!isBackKey) return KeyEventResult.ignored; - - // Toggle focus between sidebar and content - if (_isSidebarFocused) { - _focusContent(); - } else { - _focusSidebar(); - } - return KeyEventResult.handled; + // Toggle focus between sidebar and content on BACK key + return handleBackKeyAction(event, () { + if (_isSidebarFocused) { + _focusContent(); + } else { + _focusSidebar(); + } + }); } @override diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 343ed91b..e0584b91 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1200,20 +1200,29 @@ class _MediaDetailScreenState extends State with WatchStateAw // Show loading state while fetching full metadata if (_isLoadingMetadata) { - return Focus( + final loading = Focus( onKeyEvent: handleBack, child: Scaffold( appBar: AppBar(), body: const Center(child: CircularProgressIndicator()), ), ); + final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); + if (!blockSystemBack) { + return loading; + } + return PopScope( + canPop: false, // Prevent system back from double-popping on Android keyboard/TV + onPopInvokedWithResult: (didPop, result) {}, + child: loading, + ); } // Determine header height based on screen size final size = MediaQuery.of(context).size; final headerHeight = size.height * 0.6; - return Focus( + final content = Focus( onKeyEvent: handleBack, child: Scaffold( body: Stack( @@ -1618,6 +1627,17 @@ class _MediaDetailScreenState extends State with WatchStateAw ), ), ); + + final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); + if (!blockSystemBack) { + return content; + } + + return PopScope( + canPop: false, // Prevent system back from double-popping on Android keyboard/TV + onPopInvokedWithResult: (didPop, result) {}, + child: content, + ); } Widget _buildInfoRow(String label, String value) { diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index ea8c4243..4a8dc326 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -16,6 +16,7 @@ import '../../widgets/desktop_app_bar.dart'; import '../../providers/settings_provider.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/input_mode_tracker.dart'; +import '../../focus/key_event_utils.dart'; import 'playlist_item_card.dart'; import '../../i18n/strings.g.dart'; import '../../utils/dialogs.dart'; @@ -369,10 +370,24 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen 0) { @@ -411,12 +426,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen Navigator.pop(context)); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (key.isLeftKey && _appBarFocusedButton > 0) { setState(() => _appBarFocusedButton--); _focusAppBarButton(_appBarFocusedButton); @@ -525,11 +534,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen createState() => _SeasonDetailScreenState(); } -class _SeasonDetailScreenState extends State with ItemUpdatable, WatchStateAware { +class _SeasonDetailScreenState extends State with ItemUpdatable, WatchStateAware, RouteAware { PlexClient? _client; @override @@ -45,6 +49,8 @@ class _SeasonDetailScreenState extends State with ItemUpdata bool _watchStateChanged = false; // Capture keyboard mode once at init to avoid rebuild dependency bool _initialKeyboardMode = false; + bool _suppressNextBackKeyUp = false; + bool _routeSubscribed = false; // WatchStateAware: watch all episode ratingKeys @override @@ -135,10 +141,45 @@ class _SeasonDetailScreenState extends State with ItemUpdata } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_routeSubscribed) return; + final route = ModalRoute.of(context); + if (route is PageRoute) { + routeObserver.subscribe(this, route); + _routeSubscribed = true; + } + } + + @override + void dispose() { + if (_routeSubscribed) { + routeObserver.unsubscribe(this); + _routeSubscribed = false; + } + super.dispose(); + } + + @override + void didPopNext() { + // Returning from a child route (e.g., video player). + // Suppress the first BACK KeyUp which can otherwise pop this route. + _suppressNextBackKeyUp = true; + } + + KeyEventResult _handleBackKeyEvent(KeyEvent event) { + if (_suppressNextBackKeyUp && event is KeyUpEvent && event.logicalKey.isBackKey) { + _suppressNextBackKeyUp = false; + return KeyEventResult.handled; + } + return handleBackKeyNavigation(context, event, result: _watchStateChanged); + } + @override Widget build(BuildContext context) { - return Focus( - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event, result: _watchStateChanged), + final content = Focus( + onKeyEvent: (_, event) => _handleBackKeyEvent(event), child: Scaffold( body: CustomScrollView( slivers: [ @@ -200,6 +241,17 @@ class _SeasonDetailScreenState extends State with ItemUpdata ), ), ); + + final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); + if (!blockSystemBack) { + return content; + } + + return PopScope( + canPop: false, // Prevent system back from double-popping on Android keyboard/TV + onPopInvokedWithResult: (didPop, result) {}, + child: content, + ); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 9a4b7a19..803bfd1f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -43,6 +43,7 @@ import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; import '../focus/focusable_wrapper.dart'; import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../watch_together/providers/watch_together_provider.dart'; @@ -96,6 +97,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; bool _waitingForExternalSubsTrackSelection = false; + bool _isHandlingBack = false; // Auto-play next episode Timer? _autoPlayTimer; @@ -981,6 +983,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation Future _handleBackButton() async { + if (_isHandlingBack) return; + _isHandlingBack = true; + try { // For non-host participants, show leave session confirmation if (_watchTogetherProvider != null && _watchTogetherProvider!.isInSession && !_watchTogetherProvider!.isHost) { final confirmed = await showDialog( @@ -1035,6 +1040,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted) return; _isExiting.value = true; Navigator.of(context).pop(true); + } finally { + _isHandlingBack = false; + } } @override @@ -1586,8 +1594,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin return PopScope( canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing onPopInvokedWithResult: (didPop, result) { + if (BackKeyCoordinator.consumeIfHandled()) return; // Allow programmatic back navigation from UI controls - if (!didPop) _handleBackButton(); + if (!didPop) { + // Mark handled to prevent the same BACK press from reaching the next route. + BackKeyCoordinator.markHandled(); + _handleBackButton(); + } }, child: Scaffold( // Use transparent background on macOS when native video layer is active diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 9f3d9b3b..11dff708 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -6,6 +6,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; +import '../focus/key_event_utils.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart' show EpisodePosterMode; import '../theme/mono_tokens.dart'; @@ -209,6 +210,13 @@ class HubSectionState extends State { } } + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + } + // Handle key down and repeat events if (!event.isActionable) { return KeyEventResult.ignored; @@ -260,12 +268,6 @@ class HubSectionState extends State { return KeyEventResult.handled; } - // Back key: navigate to tab bar - if (key.isBackKey && widget.onBack != null) { - widget.onBack!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; } diff --git a/lib/widgets/tv_number_spinner.dart b/lib/widgets/tv_number_spinner.dart index 3ab4d90c..ae4f7f6e 100644 --- a/lib/widgets/tv_number_spinner.dart +++ b/lib/widgets/tv_number_spinner.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; import 'app_icon.dart'; import '../theme/mono_tokens.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -114,17 +115,19 @@ class _TvNumberSpinnerState extends State { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { final key = event.logicalKey; + if (widget.onCancel != null) { + final backResult = handleBackKeyAction(event, widget.onCancel!); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + } + if (event is KeyDownEvent) { // Handle SELECT key to confirm/move to save button if (key.isSelectKey && widget.onConfirm != null) { widget.onConfirm!(); return KeyEventResult.handled; } - // Handle BACK key to cancel - if (key.isBackKey && widget.onCancel != null) { - widget.onCancel!(); - return KeyEventResult.handled; - } if (key.isUpKey || key.isRightKey) { _startRepeat(_increment); return KeyEventResult.handled; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 8398aeff..8124f759 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -28,6 +28,7 @@ import '../../models/plex_media_info.dart'; import '../../models/plex_media_version.dart'; import '../../models/plex_metadata.dart'; import '../../screens/video_player_screen.dart'; +import '../../focus/key_event_utils.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; import '../../utils/platform_detector.dart'; @@ -1136,14 +1137,6 @@ class _PlexVideoControlsState extends State with WindowListen key == LogicalKeyboardKey.arrowRight; } - /// Check if a key is a back/escape key - bool _isBackKey(LogicalKeyboardKey key) { - return key == LogicalKeyboardKey.escape || - key == LogicalKeyboardKey.goBack || - key == LogicalKeyboardKey.browserBack || - key == LogicalKeyboardKey.gameButtonB; - } - /// Check if a key is a select/enter key bool _isSelectKey(LogicalKeyboardKey key) { return key == LogicalKeyboardKey.select || @@ -1300,6 +1293,23 @@ class _PlexVideoControlsState extends State with WindowListen focusNode: _focusNode, autofocus: true, onKeyEvent: (node, event) { + final backResult = handleBackKeyAction(event, () { + // On Windows/Linux with navigation off, ESC first exits fullscreen + if (!_videoPlayerNavigationEnabled && _isFullscreen && (Platform.isWindows || Platform.isLinux)) { + _toggleFullscreen(); + return; + } + if (!_showControls) { + _showControlsWithFocus(); + return; + } + // Controls visible - navigate back + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + }); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + // Only handle KeyDown and KeyRepeat events if (!event.isActionable) { return KeyEventResult.ignored; @@ -1347,23 +1357,6 @@ class _PlexVideoControlsState extends State with WindowListen return KeyEventResult.handled; } - // Handle Back/Escape: show controls if hidden, navigate back if visible - if (_isBackKey(key)) { - // On Windows/Linux with navigation off, ESC first exits fullscreen - if (!_videoPlayerNavigationEnabled && _isFullscreen && (Platform.isWindows || Platform.isLinux)) { - _toggleFullscreen(); - return KeyEventResult.handled; - } - - if (!_showControls) { - _showControlsWithFocus(); - return KeyEventResult.handled; - } - // Controls visible - navigate back - (widget.onBack ?? () => Navigator.of(context).pop(true))(); - return KeyEventResult.handled; - } - // Handle Select/Enter when controls are hidden: pause and show controls // Only intercept if this Focus node itself has primary focus (not a descendant) if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) { diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index 82dece62..df966e43 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../../../focus/dpad_navigator.dart'; +import '../../../focus/key_event_utils.dart'; import '../../../mpv/mpv.dart'; import '../../../services/settings_service.dart'; import '../../../i18n/strings.g.dart'; @@ -92,16 +93,20 @@ class _VolumeControlState extends State { } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (!event.isActionable) { - return KeyEventResult.ignored; - } - final key = event.logicalKey; if (_isAdjustMode) { + if (key.isBackKey) { + return handleBackKeyAction(event, _exitAdjustMode); + } + // Notify activity on any key in adjust mode (to reset hide timer) widget.onFocusActivity?.call(); + if (!event.isActionable) { + return KeyEventResult.ignored; + } + // In adjust mode: left/right adjusts volume, back/escape exits if (key == LogicalKeyboardKey.arrowLeft) { _adjustVolume(-_volumeStep); @@ -111,7 +116,7 @@ class _VolumeControlState extends State { _adjustVolume(_volumeStep); return KeyEventResult.handled; } - if (key.isBackKey || key.isSelectKey) { + if (key.isSelectKey) { _exitAdjustMode(); return KeyEventResult.handled; } @@ -125,6 +130,10 @@ class _VolumeControlState extends State { return KeyEventResult.handled; } + if (!event.isActionable) { + return KeyEventResult.ignored; + } + // Not in adjust mode: use the provided key event handler for navigation return widget.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; }