diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index c7a268ce..4abc5b82 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -57,3 +57,23 @@ extension DpadKeyExtension on LogicalKeyboardKey { /// Whether this key moves focus down. bool get isDownKey => this == LogicalKeyboardKey.arrowDown; } + +/// Global helper to suppress the next SELECT key-up event. +class SelectKeyUpSuppressor { + static bool _suppressSelectUntilKeyUp = false; + + static void suppressSelectUntilKeyUp() { + _suppressSelectUntilKeyUp = true; + } + + static bool consumeIfSuppressed(KeyEvent event) { + if (!_suppressSelectUntilKeyUp) return false; + if (event.logicalKey.isSelectKey) { + if (event is KeyUpEvent) { + _suppressSelectUntilKeyUp = false; + } + return true; + } + return false; + } +} diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 6e8daf72..1fce4f9f 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -182,6 +182,12 @@ class _FocusableWrapperState extends State with SingleTickerPr _isFocused = hasFocus; }); + // Reset long press state when focus is lost + if (!hasFocus) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + } + // Animate scale if (hasFocus) { _animationController.forward(); @@ -253,6 +259,10 @@ class _FocusableWrapperState extends State with SingleTickerPr KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { final key = event.logicalKey; + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + // Call custom key handler first if (widget.onKeyEvent != null) { final result = widget.onKeyEvent!(node, event); diff --git a/lib/widgets/focusable_bottom_sheet.dart b/lib/widgets/focusable_bottom_sheet.dart index 0c88865d..cee4b6d2 100644 --- a/lib/widgets/focusable_bottom_sheet.dart +++ b/lib/widgets/focusable_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../focus/input_mode_tracker.dart'; +import '../focus/dpad_navigator.dart'; /// A wrapper widget that provides autofocus functionality for bottom sheets. /// @@ -56,6 +57,16 @@ class _FocusableBottomSheetState extends State { @override Widget build(BuildContext context) { - return widget.child; + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: widget.child, + ); } } diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 1082ba75..b0c0b95f 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../focus/dpad_navigator.dart'; /// A ListTile that accepts a FocusNode for keyboard/controller navigation. /// /// Uses Flutter's native ListTile focus support - no custom styling wrapper. /// The focusNode allows programmatic focus control (e.g., auto-focus first item). -class FocusableListTile extends StatelessWidget { +class FocusableListTile extends StatefulWidget { /// The primary content of the list tile. final Widget? title; @@ -41,6 +43,9 @@ class FocusableListTile extends StatelessWidget { /// The tile's internal padding. final EdgeInsetsGeometry? contentPadding; + /// If true, consumes the first select key event to avoid accidental activation. + final bool suppressInitialSelect; + const FocusableListTile({ super.key, this.title, @@ -55,23 +60,51 @@ class FocusableListTile extends StatelessWidget { this.focusNode, this.autofocus = false, this.contentPadding, + this.suppressInitialSelect = false, }); + @override + State createState() => _FocusableListTileState(); +} + +class _FocusableListTileState extends State { + bool _suppressionConsumed = false; + @override Widget build(BuildContext context) { - return ListTile( - title: title, - subtitle: subtitle, - leading: leading, - trailing: trailing, - onTap: onTap, - onLongPress: onLongPress, - dense: dense, - enabled: enabled, - selected: selected, - contentPadding: contentPadding, - focusNode: focusNode, - autofocus: autofocus, + final tile = ListTile( + title: widget.title, + subtitle: widget.subtitle, + leading: widget.leading, + trailing: widget.trailing, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + dense: widget.dense, + enabled: widget.enabled, + selected: widget.selected, + contentPadding: widget.contentPadding, + focusNode: widget.suppressInitialSelect ? null : widget.focusNode, + autofocus: widget.suppressInitialSelect ? false : widget.autofocus, + ); + + if (!widget.suppressInitialSelect) { + return tile; + } + + return Focus( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onKeyEvent: (node, event) { + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + if (!_suppressionConsumed && event.logicalKey.isSelectKey) { + _suppressionConsumed = true; + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: tile, ); } } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index e53cd2c8..9f3d9b3b 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + 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'; @@ -61,6 +64,8 @@ class HubSection extends StatefulWidget { } class HubSectionState extends State { + static const _longPressDuration = Duration(milliseconds: 500); + late FocusNode _hubFocusNode; final ScrollController _scrollController = ScrollController(); @@ -71,6 +76,10 @@ class HubSectionState extends State { double _itemExtent = 0; static const double _leadingPadding = 12.0; + Timer? _longPressTimer; + bool _isSelectKeyDown = false; + bool _longPressTriggered = false; + @override void initState() { super.initState(); @@ -92,6 +101,7 @@ class HubSectionState extends State { @override void dispose() { + _longPressTimer?.cancel(); _hubFocusNode.removeListener(_onFocusChange); _hubFocusNode.dispose(); _scrollController.dispose(); @@ -99,6 +109,12 @@ class HubSectionState extends State { } void _onFocusChange() { + // Reset long press state when focus is lost + if (!_hubFocusNode.hasFocus) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + _longPressTriggered = false; + } // Rebuild to update visual focus state if (mounted) setState(() {}); } @@ -161,12 +177,43 @@ class HubSectionState extends State { /// Handle ALL key events at the hub level KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + if (key.isSelectKey) { + if (event is KeyDownEvent) { + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTriggered = false; + _longPressTimer?.cancel(); + _longPressTimer = Timer(_longPressDuration, () { + if (!mounted) return; + if (_isSelectKeyDown) { + _longPressTriggered = true; + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _showContextMenuForCurrentItem(); + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) { + _activateCurrentItem(); + } + _isSelectKeyDown = false; + _longPressTriggered = false; + return KeyEventResult.handled; + } + } + // Handle key down and repeat events if (!event.isActionable) { return KeyEventResult.ignored; } - final key = event.logicalKey; final itemCount = widget.hub.items.length; if (itemCount == 0) return KeyEventResult.ignored; @@ -207,12 +254,6 @@ class HubSectionState extends State { return KeyEventResult.handled; } - // Select: activate the current item - if (key.isSelectKey) { - _activateCurrentItem(); - return KeyEventResult.handled; - } - // Context menu key: show context menu if (key.isContextMenuKey) { _showContextMenuForCurrentItem(); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index ceec4b1d..8708f4fe 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -16,6 +16,7 @@ import '../utils/library_refresh_notifier.dart'; import '../utils/snackbar_helper.dart'; import '../utils/dialogs.dart'; import '../utils/focus_utils.dart'; +import '../focus/dpad_navigator.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../utils/smart_deletion_handler.dart'; @@ -1222,44 +1223,54 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { top = screenSize.height - estimatedHeight - 8; } - return Stack( - children: [ - // Barrier to close menu when clicking outside - Positioned.fill( - child: GestureDetector( - onTap: () => Navigator.pop(context), - behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Stack( + children: [ + // Barrier to close menu when clicking outside + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), ), - ), - // Menu - Positioned( - left: left, - top: top, - child: Material( - elevation: 8, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.actions.asMap().entries.map((entry) { - final index = entry.key; - final action = entry.value; - return FocusableListTile( - focusNode: index == 0 ? _initialFocusNode : null, - leading: AppIcon(action.icon, fill: 1, size: 20), - title: Text(action.label), - onTap: () => Navigator.pop(context, action.value), - ); - }).toList(), + // Menu + Positioned( + left: left, + top: top, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + leading: AppIcon(action.icon, fill: 1, size: 20), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + ); + }).toList(), + ), ), ), ), - ), - ], + ], + ), ); } }