import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../exceptions/media_server_exceptions.dart'; import '../focus/focusable_text_field.dart'; import '../i18n/strings.g.dart'; import '../media/ids.dart'; import '../media/media_item.dart'; import '../mixins/debounced_media_search.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/refreshable.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../services/data_aggregation_service.dart'; import '../utils/app_logger.dart'; import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../utils/media_server_http_client.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/search_input_field.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, MountedSetStateMixin, DebouncedMediaSearch { String? _focusResultsForQuery; final _tvTextInputController = TvTextInputController(); AbortController? _activeSearchAbort; ({String query, SearchAggregationResult result})? _pendingSearchOutcome; HiddenLibrariesProvider? _hiddenLibraries; Set _lastSeenHiddenKeys = const {}; @override void initState() { super.initState(); FocusUtils.requestFocusAfterBuild(this, searchFocusNode); unawaited(_bindHiddenLibraries()); } @override void dispose() { _hiddenLibraries?.removeListener(_onHiddenLibrariesChanged); super.dispose(); } /// Re-run the visible query when a library is hidden or unhidden while /// results are on screen. The listener is attached only after the provider /// has hydrated, so its initial load notification cannot race the first /// query — which awaits that same hydration — into running twice. Future _bindHiddenLibraries() async { final hiddenLibraries = context.read(); await hiddenLibraries.ensureInitialized(); if (!mounted) return; _lastSeenHiddenKeys = Set.of(hiddenLibraries.hiddenLibraryKeys); _hiddenLibraries = hiddenLibraries..addListener(_onHiddenLibrariesChanged); } void _onHiddenLibrariesChanged() { final hiddenLibraries = _hiddenLibraries; if (hiddenLibraries == null || !mounted) return; final currentKeys = hiddenLibraries.hiddenLibraryKeys; if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) { return; } _lastSeenHiddenKeys = Set.of(currentKeys); final query = searchController.text.trim(); if (query.isEmpty || !hasSearched) return; unawaited(runSearch(query)); } @override String get searchDebugLabel => 'Search'; @override Future> performSearchQuery(String query) async { final multiServerProvider = Provider.of(context, listen: false); if (!multiServerProvider.hasConnectedServers) { throw const _SearchUnavailableException(); } // Hidden-library keys must be hydrated before the first query, or a cold // start would filter nothing. Read before any await: the profile subtree // can be torn down mid-search. final hiddenLibraries = context.read(); await hiddenLibraries.ensureInitialized(); if (!mounted) return const []; final abort = AbortController(); _activeSearchAbort = abort; try { final result = await multiServerProvider.aggregationService.searchAcrossServers( query, hiddenLibraryKeys: hiddenLibraries.hiddenLibraryKeys, abort: abort, ); abort.throwIfAborted(); if (result.succeededServerIds.isEmpty && result.failedServerIds.isNotEmpty) { throw const _SearchUnavailableException(); } if (result.succeededServerIds.isEmpty && result.cancelledServerIds.isNotEmpty) { throw MediaServerHttpException( type: MediaServerHttpErrorType.cancelled, message: 'Search was cancelled before any server completed', ); } _pendingSearchOutcome = (query: query, result: result); return result.items; } finally { if (identical(_activeSearchAbort, abort)) _activeSearchAbort = null; } } @override void onSearchInvalidated() { _activeSearchAbort?.abort(); _activeSearchAbort = null; _pendingSearchOutcome = null; } @override void onSearchError(Object error) { _focusResultsForQuery = null; _pendingSearchOutcome = null; final message = error is _SearchUnavailableException ? t.errors.searchUnavailable : t.errors.searchFailed(error: error); showErrorSnackBar(context, message); } @override void onSearchCleared() { _focusResultsForQuery = null; _pendingSearchOutcome = null; } @override void onSearchCompleted(String query, List results) { final outcome = _pendingSearchOutcome; _pendingSearchOutcome = null; if (outcome?.query == query && outcome!.result.failedServerIds.isNotEmpty) { showAppSnackBar(context, t.messages.searchPartialResults); } if (_focusResultsForQuery == null || _focusResultsForQuery != query) return; _focusResultsForQuery = null; if (results.isEmpty) return; if (searchController.text.trim() != query) return; // user kept editing FocusUtils.requestFocusAfterBuild(this, firstResultFocusNode); } /// OSK "Search" / hardware Enter on TV additionally focuses the results /// when the forced search lands. @override void handleSearchSubmit() { final query = searchController.text.trim(); if (query.isEmpty) return; if (searchResults.isEmpty || isSearching || query != lastSearchedQuery) { _focusResultsForQuery = query; } super.handleSearchSubmit(); } @override void refresh() { if (!mounted) return; runSearch(searchController.text.trim()); } /// Focus the search input field @override void focusSearchInput() { if (!mounted) return; searchFocusNode.requestFocus(); } @override void focusActiveTabIfReady() { if (!mounted) return; searchFocusNode.requestFocus(); } /// Apply a complete query submitted from the Plezy companion remote: set the /// text, dismiss any open on-screen keyboard, land focus on the input without /// (re)opening the OSK, and run the search now — the first result takes focus /// when it lands (via onSearchCompleted). The user already typed the query on /// their phone, so the TV keyboard must never be up afterwards. @override void submitSearchQuery(String query) { if (!mounted) return; final trimmed = query.trim(); searchController.text = trimmed; // listener arms the debounce / resets state // Focusing the field normally auto-opens the OSK; a remote search must not // show it, and must dismiss one the TV user already had open (the phone's // Search chip sends tabSearch before the query arrives). _tvTextInputController.closeTextInput(); if (trimmed.isEmpty) return; // Land focus on the (visible) input immediately so the D-pad remote is // never stranded on the hidden previous tab — while the search is in // flight, when it fails, and when it returns nothing. _tvTextInputController.focusInputWithoutOpening(); // Same path as the OSK Search key: jumps straight to already-matching // results, or cancels the debounce and runs now; the screen override arms // _focusResultsForQuery so results take focus when they land. handleSearchSubmit(); } // Public method to fully reload all content (for profile switches) @override void fullRefresh() { if (!mounted) return; appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading'); // Clearing the field resets the search state through the text listener. _focusResultsForQuery = null; searchController.clear(); } Future updateItem(MediaItem source) async { if (!mounted) return; final serverId = source.serverId; if (serverId == null) return; try { final multiServer = context.read(); final updated = await multiServer.getClientForServer(ServerId(serverId))?.fetchItem(source.id); if (!mounted || updated == null) return; final index = searchResults.indexWhere((item) => item.globalKey == source.globalKey); if (index == -1) return; setState(() { searchResults[index] = updated; }); } catch (e) { appLogger.d('Search item refresh skipped for ${source.globalKey}', error: e); } } /// Navigate focus to the sidebar void _navigateToSidebar() { MainScreenFocusScope.focusSidebarOf(context); } Widget _buildResultsList(BuildContext context) { final multiServer = context.watch(); final showServerName = multiServer.totalServerCount > 1; return buildResultsSliver((context, index) { final item = searchResults[index]; return FocusableMediaCard( key: Key(item.globalKey), item: item, forceListMode: true, disableScale: true, focusNode: index == 0 ? firstResultFocusNode : null, onRefresh: updateItem, onListRefresh: refresh, onNavigateLeft: _navigateToSidebar, onNavigateUp: index == 0 ? focusSearchInput : null, showServerName: showServerName, ); }); } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: CustomScrollView( primary: false, slivers: [ DesktopSliverAppBar(title: Text(t.common.search), floating: true), SliverToBoxAdapter( child: SearchInputField( controller: searchController, focusNode: searchFocusNode, debugLabel: searchDebugLabel, hintText: t.search.hint, tvTextInputController: _tvTextInputController, onNavigateLeft: _navigateToSidebar, onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, onBack: () { if (searchController.text.isNotEmpty) { searchController.clear(); } else { _navigateToSidebar(); } }, ), ), if (isSearching) LoadingIndicatorBox.sliver else if (!hasSearched) SliverFillRemaining( child: StateMessageWidget( message: t.search.searchYourMedia, subtitle: t.search.enterTitleActorOrKeyword, icon: Symbols.search_rounded, iconSize: 80, ), ) else if (lastSearchFailed) SliverFillRemaining( child: StateMessageWidget(message: t.explore.searchFailed, icon: Symbols.error_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), ], ), ), ); } } final class _SearchUnavailableException implements Exception { const _SearchUnavailableException(); }