diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 30147b63..c6d3e00b 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,9 +1,11 @@ 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 'package:rate_limiter/rate_limiter.dart'; +import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; import '../mixins/refreshable.dart'; import '../models/plex_metadata.dart'; @@ -30,6 +32,7 @@ class SearchScreen extends StatefulWidget { class _SearchScreenState extends State with Refreshable, FullRefreshable, SearchInputFocusable { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(debugLabel: 'SearchInput'); + final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult'); List _searchResults = []; bool _isSearching = false; bool _hasSearched = false; @@ -51,6 +54,7 @@ class _SearchScreenState extends State with Refreshable, FullRefre _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _searchFocusNode.dispose(); + _firstResultFocusNode.dispose(); super.dispose(); } @@ -156,6 +160,37 @@ class _SearchScreenState extends State with Refreshable, FullRefre MainScreenFocusScope.of(context)?.focusSidebar(); } + /// Handle key events on the search input for D-pad navigation + KeyEventResult _handleSearchInputKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) return KeyEventResult.ignored; + + final key = event.logicalKey; + + // DOWN: Focus first result if results exist and not loading + if (key.isDownKey && _searchResults.isNotEmpty && !_isSearching) { + _firstResultFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // LEFT at cursor position 0: Navigate to sidebar + if (key.isLeftKey && _searchController.selection.baseOffset == 0) { + _navigateToSidebar(); + return KeyEventResult.handled; + } + + // BACK: Clear search or navigate to sidebar + if (key.isBackKey) { + if (_searchController.text.isNotEmpty) { + _searchController.clear(); + } else { + _navigateToSidebar(); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + /// Calculate column count based on available width. int _calculateColumnCount(double availableWidth, double maxCrossAxisExtent, double crossAxisSpacing) { return ((availableWidth + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); @@ -176,33 +211,36 @@ class _SearchScreenState extends State with Refreshable, FullRefre SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - decoration: InputDecoration( - hintText: t.search.hint, - prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const AppIcon(Symbols.clear_rounded, fill: 1), - onPressed: () { - _searchController.clear(); - // State update handled by listener - }, - ) - : null, - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide.none, + child: Focus( + onKeyEvent: _handleSearchInputKeyEvent, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: t.search.hint, + prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const AppIcon(Symbols.clear_rounded, fill: 1), + onPressed: () { + _searchController.clear(); + // State update handled by listener + }, + ) + : null, + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ), ), @@ -243,11 +281,14 @@ class _SearchScreenState extends State with Refreshable, FullRefre itemBuilder: (context, item, index) { // In list view, all items are in the first column final isFirstColumn = isList || GridSizeCalculator.isFirstColumn(index, columnCount); + final isFirstRow = isList ? index == 0 : GridSizeCalculator.isFirstRow(index, columnCount); return FocusableMediaCard( key: Key(item.ratingKey), item: item, + focusNode: index == 0 ? _firstResultFocusNode : null, onListRefresh: () => updateItem(item.ratingKey), onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, + onNavigateUp: isFirstRow ? focusSearchInput : null, ); }, viewMode: settingsProvider.viewMode,