import 'package:flutter/material.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'; import '../providers/multi_server_provider.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/pill_input_decoration.dart'; import '../widgets/focusable_media_card.dart'; import '../utils/focus_utils.dart'; import 'libraries/state_messages.dart'; import 'main_screen.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @override State createState() => _SearchScreenState(); } class _SearchScreenState extends State with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(debugLabel: 'SearchInput'); final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult'); List _searchResults = []; bool _isSearching = false; bool _hasSearched = false; late final Debounce _searchDebounce; String _lastSearchedQuery = ''; @override void initState() { super.initState(); _searchDebounce = debounce(_performSearch, const Duration(milliseconds: 500)); _searchController.addListener(_onSearchChanged); // Focus the search input when the screen is shown FocusUtils.requestFocusAfterBuild(this, _searchFocusNode); } @override void dispose() { _searchDebounce.cancel(); _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _searchFocusNode.dispose(); _firstResultFocusNode.dispose(); super.dispose(); } void _onSearchChanged() { final query = _searchController.text; if (query.trim().isEmpty) { _searchDebounce.cancel(); setState(() { _searchResults = []; _hasSearched = false; _isSearching = false; _lastSearchedQuery = ''; }); return; } // Only search if the query has actually changed if (query.trim() == _lastSearchedQuery.trim()) { return; } _searchDebounce([query]); } Future _performSearch(String query) async { if (query.trim().isEmpty) { setState(() { _searchResults = []; _hasSearched = false; }); return; } setState(() { _isSearching = true; _hasSearched = true; }); try { final multiServerProvider = Provider.of(context, listen: false); if (!multiServerProvider.hasConnectedServers) { throw Exception('No servers available'); } // Search across all connected servers final results = await multiServerProvider.aggregationService.searchAcrossServers(query); if (mounted) { setState(() { _searchResults = results; _isSearching = false; _lastSearchedQuery = query.trim(); }); } } catch (e) { if (mounted) { setState(() { _isSearching = false; }); showErrorSnackBar(context, t.errors.searchFailed(error: e)); } } } @override void refresh() { // Re-run the current search if there is one if (_searchController.text.isNotEmpty) { _performSearch(_searchController.text); } } /// Focus the search input field @override void focusSearchInput() { _searchFocusNode.requestFocus(); } @override void focusActiveTabIfReady() { _searchFocusNode.requestFocus(); } /// Set the search query externally (e.g. from companion remote) @override void setSearchQuery(String query) { _searchController.text = query; } // Public method to fully reload all content (for profile switches) @override void fullRefresh() { appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading'); // Clear search results and search text for new profile _searchController.clear(); setState(() { _searchResults.clear(); _isSearching = false; _hasSearched = false; _lastSearchedQuery = ''; }); } void updateItem(String _) { // Trigger a refresh of the search to get updated metadata if (_searchController.text.isNotEmpty) { _performSearch(_searchController.text); } } /// Navigate focus to the sidebar void _navigateToSidebar() { MainScreenFocusScope.of(context)?.focusSidebar(); } /// Handle key events on the search input for D-pad navigation KeyEventResult _handleSearchInputKeyEvent(FocusNode _, 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; } Widget _buildResultsList(BuildContext context) { final multiServer = context.watch(); final showServerName = multiServer.totalServerCount > 1; return SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = _searchResults[index]; return FocusableMediaCard( key: Key(item.globalKey), item: item, forceListMode: true, disableScale: true, focusNode: index == 0 ? _firstResultFocusNode : null, onListRefresh: () => updateItem(item.ratingKey), onNavigateLeft: _navigateToSidebar, onNavigateUp: index == 0 ? focusSearchInput : null, showServerName: showServerName, ); }, childCount: _searchResults.length), ), ); } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: CustomScrollView( primary: false, slivers: [ DesktopSliverAppBar(title: Text(t.common.search), floating: true), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), child: Focus( onKeyEvent: _handleSearchInputKeyEvent, child: TextField( controller: _searchController, focusNode: _searchFocusNode, decoration: pillInputDecoration( context, 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, ), ), ), ), ), if (_isSearching) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) else if (!_hasSearched) SliverFillRemaining( child: StateMessageWidget( message: t.search.searchYourMedia, subtitle: t.search.enterTitleActorOrKeyword, icon: Symbols.search_rounded, iconSize: 80, ), ) else if (_searchResults.isEmpty) SliverFillRemaining( child: StateMessageWidget( message: t.messages.noResultsFound, subtitle: t.search.tryDifferentTerm, icon: Symbols.search_off_rounded, iconSize: 80, ), ) else _buildResultsList(context), ], ), ), ); } }