fix: back improvements

This commit is contained in:
edde746
2026-01-24 21:40:02 +01:00
parent b7ec24c2c1
commit 0fc2708a51
15 changed files with 268 additions and 133 deletions
+10 -8
View File
@@ -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<T extends StatefulWidget> on State<T> {
/// 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<T extends StatefulWidget> on State<T> {
return KeyEventResult.handled;
}
// BACK key
if (key.isBackKey && callbacks.onBack != null) {
callbacks.onBack!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
}
+8 -6
View File
@@ -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<FocusableWrapper> 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<FocusableWrapper> with SingleTickerPr
return KeyEventResult.handled;
}
// BACK key
if (key.isBackKey && widget.onBack != null) {
widget.onBack!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
+44 -7
View File
@@ -28,16 +28,53 @@ import 'dpad_navigator.dart';
/// child: ...
/// )
/// ```
KeyEventResult handleBackKeyNavigation<T>(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<T>(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));
}
@@ -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<CollectionD
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (didPop) return;
final shouldPop = handleBackNavigation();
if (shouldPop && mounted) {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../widgets/app_icon.dart';
/// Configuration for app bar buttons
@@ -141,11 +142,16 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
/// 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<T extends StatefulWidget> on State<T> {
}
return KeyEventResult.handled;
}
if (key.isBackKey) {
// Already on app bar, exit the screen
Navigator.pop(context);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
+23 -19
View File
@@ -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;
+9 -19
View File
@@ -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<MainScreen> 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
+22 -2
View File
@@ -1200,20 +1200,29 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> 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<MediaDetailScreen> 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) {
@@ -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<PlaylistDetai
/// Handle key events for list navigation
KeyEventResult _handleListKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final key = event.logicalKey;
final backResult = handleBackKeyAction(event, () {
if (_movingIndex != null) {
// Cancel move mode, set flag to prevent PopScope exit
_backHandledByKeyEvent = true;
_cancelMoveMode();
} else {
// Navigate to app bar on BACK, set flag to prevent PopScope exit
_handleBackFromContent();
}
});
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) {
@@ -411,12 +426,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
_persistMoveToServer(oldIndex, newIndex);
return KeyEventResult.handled;
}
if (key.isBackKey) {
// Cancel move mode, set flag to prevent PopScope exit
_backHandledByKeyEvent = true;
_cancelMoveMode();
return KeyEventResult.handled;
}
} else {
// Navigation mode
if (key.isUpKey) {
@@ -481,11 +490,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
return KeyEventResult.handled;
}
if (key.isBackKey) {
// Navigate to app bar on BACK, set flag to prevent PopScope exit
_handleBackFromContent();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
@@ -493,12 +497,17 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
/// 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 hasDelete = !widget.playlist.smart;
final maxButton = hasDelete ? 2 : 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);
@@ -525,11 +534,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
_triggerAppBarButton(_appBarFocusedButton);
return KeyEventResult.handled;
}
if (key.isBackKey) {
// Already on app bar, exit the screen
Navigator.pop(context);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@@ -701,6 +705,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (didPop) return;
final shouldPop = _handleBackNavigation();
if (shouldPop && mounted) {
+55 -3
View File
@@ -1,11 +1,14 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../services/plex_client.dart';
import '../main.dart';
import '../focus/key_event_utils.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../models/download_models.dart';
import '../providers/download_provider.dart';
@@ -23,6 +26,7 @@ import '../mixins/watch_state_aware.dart';
import '../utils/watch_state_notifier.dart';
import '../theme/mono_tokens.dart';
import '../i18n/strings.g.dart';
import '../utils/platform_detector.dart';
class SeasonDetailScreen extends StatefulWidget {
final PlexMetadata season;
@@ -34,7 +38,7 @@ class SeasonDetailScreen extends StatefulWidget {
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
}
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable, WatchStateAware {
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable, WatchStateAware, RouteAware {
PlexClient? _client;
@override
@@ -45,6 +49,8 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> 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<SeasonDetailScreen> 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<SeasonDetailScreen> 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,
);
}
}
+14 -1
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
/// Handle back button press
/// For non-host participants in Watch Together, shows leave session confirmation
Future<void> _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<bool>(
@@ -1035,6 +1040,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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
+8 -6
View File
@@ -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<HubSection> {
}
}
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<HubSection> {
return KeyEventResult.handled;
}
// Back key: navigate to tab bar
if (key.isBackKey && widget.onBack != null) {
widget.onBack!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
+8 -5
View File
@@ -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<TvNumberSpinner> {
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;
+18 -25
View File
@@ -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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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) {
@@ -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<VolumeControl> {
}
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<VolumeControl> {
_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<VolumeControl> {
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;
}