Searching a name shared by a movie, a show, and audio drowns the wanted item in episode and track rows. The search screen now offers kind chips (All, Movies, TV Shows, ...) above the results; chips derive from the pre-rank candidate pool, and selecting one re-ranks that kind's candidates with the full display budget, so a kind crowded out of the trimmed All view still surfaces everything the servers returned for it. Chip switches never refetch, and the filter falls back to All when a refined query no longer returns its kind. close #2001
513 lines
19 KiB
Dart
513 lines
19 KiB
Dart
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 '../media/media_kind.dart';
|
|
import '../media/media_item_merge.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/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 '../utils/search_relevance.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 '../widgets/focusable_tab_chip.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<SearchScreen> createState() => _SearchScreenState();
|
|
}
|
|
|
|
class _SearchScreenState extends State<SearchScreen>
|
|
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
|
|
String? _focusResultsForQuery;
|
|
final _tvTextInputController = TvTextInputController();
|
|
AbortController? _activeSearchAbort;
|
|
({String query, SearchAggregationResult result})? _pendingSearchOutcome;
|
|
|
|
/// Media-kind filter over the current results. Chips and the filtered view
|
|
/// derive from [_searchCandidates] — the pre-rank pool behind the ranked
|
|
/// [searchResults] — so a kind ranked out of the "All" top-N still gets a
|
|
/// chip, and selecting it shows everything the servers returned for it.
|
|
MediaKind? _selectedKind;
|
|
List<MediaItem> _searchCandidates = const [];
|
|
List<MediaKind> _candidateKinds = const [];
|
|
List<MediaItem>? _rankedKindResults;
|
|
late final FocusNode _allChipFocusNode = FocusNode(debugLabel: 'SearchKindChipAll');
|
|
final Map<MediaKind, FocusNode> _kindChipFocusNodes = {};
|
|
|
|
HiddenLibrariesProvider? _hiddenLibraries;
|
|
Set<String> _lastSeenHiddenKeys = const {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
|
unawaited(_bindHiddenLibraries());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_hiddenLibraries?.removeListener(_onHiddenLibrariesChanged);
|
|
_allChipFocusNode.dispose();
|
|
for (final node in _kindChipFocusNodes.values) {
|
|
node.dispose();
|
|
}
|
|
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<void> _bindHiddenLibraries() async {
|
|
final hiddenLibraries = context.read<HiddenLibrariesProvider>();
|
|
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<List<MediaItem>> performSearchQuery(String query) async {
|
|
final multiServerProvider = Provider.of<MultiServerProvider>(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<HiddenLibrariesProvider>();
|
|
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;
|
|
_resetKindFilter();
|
|
final message = error is _SearchUnavailableException
|
|
? t.errors.searchUnavailable
|
|
: t.errors.searchFailed(error: error);
|
|
showErrorSnackBar(context, message);
|
|
}
|
|
|
|
@override
|
|
void onSearchCleared() {
|
|
_focusResultsForQuery = null;
|
|
_pendingSearchOutcome = null;
|
|
_resetKindFilter();
|
|
}
|
|
|
|
@override
|
|
void onSearchCompleted(String query, List<MediaItem> results) {
|
|
final outcome = _pendingSearchOutcome;
|
|
_pendingSearchOutcome = null;
|
|
final matched = outcome != null && outcome.query == query ? outcome.result : null;
|
|
|
|
// Committed alongside the results the pending setState renders (build has
|
|
// not run yet): the pre-rank pool the kind chips derive from. A selected
|
|
// filter survives a refined query as long as its kind still has
|
|
// candidates; otherwise it falls back to "All".
|
|
_searchCandidates = matched?.candidates ?? results;
|
|
_candidateKinds = _kindsIn(_searchCandidates);
|
|
if (_selectedKind != null && !_candidateKinds.contains(_selectedKind)) _selectedKind = null;
|
|
_rankedKindResults = _rankKindResults(_selectedKind, query);
|
|
|
|
if (matched != null && matched.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<void> updateItem(MediaItem source) async {
|
|
if (!mounted) return;
|
|
final serverId = source.serverId;
|
|
if (serverId == null) return;
|
|
|
|
try {
|
|
final multiServer = context.read<MultiServerProvider>();
|
|
final updated = await multiServer.getClientForServer(ServerId(serverId))?.fetchItem(source.id);
|
|
if (!mounted || updated == null) return;
|
|
// A fresh Jellyfin `fetchItem` carries no library field, and a
|
|
// library-less row would lose its label (#1970); keep the identity
|
|
// and library context the search stamped on the original row.
|
|
final merged = mergeFetchedMediaItem(fetched: updated, fallbackServerId: ServerId(serverId), existing: source);
|
|
// The same row can sit in the ranked "All" list, the kind-filtered
|
|
// view, and the candidate pool; refresh every copy so a later chip
|
|
// switch cannot resurrect the stale row.
|
|
var replaced = _replaceByGlobalKey(searchResults, source.globalKey, merged);
|
|
replaced = _replaceByGlobalKey(_searchCandidates, source.globalKey, merged) || replaced;
|
|
final filtered = _rankedKindResults;
|
|
if (filtered != null) {
|
|
replaced = _replaceByGlobalKey(filtered, source.globalKey, merged) || replaced;
|
|
}
|
|
if (replaced) setState(() {});
|
|
} catch (e) {
|
|
appLogger.d('Search item refresh skipped for ${source.globalKey}', error: e);
|
|
}
|
|
}
|
|
|
|
/// Navigate focus to the sidebar
|
|
void _navigateToSidebar() {
|
|
MainScreenFocusScope.focusSidebarOf(context);
|
|
}
|
|
|
|
/// Chip display order. `folder`/`unknown` are never search kinds, and
|
|
/// `clip`/`photo` rows (rare Plex extras) stay reachable under "All"
|
|
/// without a chip of their own.
|
|
static const List<MediaKind> _kindFilterOrder = [
|
|
MediaKind.movie,
|
|
MediaKind.show,
|
|
MediaKind.season,
|
|
MediaKind.episode,
|
|
MediaKind.artist,
|
|
MediaKind.album,
|
|
MediaKind.track,
|
|
MediaKind.collection,
|
|
MediaKind.playlist,
|
|
];
|
|
|
|
static List<MediaKind> _kindsIn(List<MediaItem> candidates) {
|
|
if (candidates.isEmpty) return const [];
|
|
final present = {for (final item in candidates) item.kind};
|
|
return [
|
|
for (final kind in _kindFilterOrder)
|
|
if (present.contains(kind)) kind,
|
|
];
|
|
}
|
|
|
|
static bool _replaceByGlobalKey(List<MediaItem> items, String globalKey, MediaItem replacement) {
|
|
final index = items.indexWhere((item) => item.globalKey == globalKey);
|
|
if (index == -1) return false;
|
|
items[index] = replacement;
|
|
return true;
|
|
}
|
|
|
|
/// The list the results sliver renders: the aggregation's ranked list, or
|
|
/// the selected kind's candidates re-ranked with the full display budget.
|
|
List<MediaItem> get _visibleResults => _rankedKindResults ?? searchResults;
|
|
|
|
/// A single-kind result set has nothing to filter.
|
|
bool get _showKindChips => _candidateKinds.length > 1;
|
|
|
|
List<MediaItem>? _rankKindResults(MediaKind? kind, String query) {
|
|
if (kind == null) return null;
|
|
return rankMediaSearchResults(
|
|
[
|
|
for (final item in _searchCandidates)
|
|
if (item.kind == kind) item,
|
|
],
|
|
query,
|
|
limit: defaultMediaSearchLimit,
|
|
);
|
|
}
|
|
|
|
void _selectKindFilter(MediaKind? kind) {
|
|
if (kind == _selectedKind) return;
|
|
setState(() {
|
|
_selectedKind = kind;
|
|
_rankedKindResults = _rankKindResults(kind, lastSearchedQuery);
|
|
});
|
|
}
|
|
|
|
void _resetKindFilter() {
|
|
_selectedKind = null;
|
|
_searchCandidates = const [];
|
|
_candidateKinds = const [];
|
|
_rankedKindResults = null;
|
|
}
|
|
|
|
FocusNode _chipFocusNode(MediaKind? kind) {
|
|
if (kind == null) return _allChipFocusNode;
|
|
return _kindChipFocusNodes.putIfAbsent(kind, () => FocusNode(debugLabel: 'SearchKindChip_${kind.id}'));
|
|
}
|
|
|
|
/// D-pad landing point for the chip row: the chip that is currently active.
|
|
void _focusKindChips() => _chipFocusNode(_selectedKind).requestFocus();
|
|
|
|
void _focusFirstResult() {
|
|
if (_visibleResults.isNotEmpty) firstResultFocusNode.requestFocus();
|
|
}
|
|
|
|
String _kindFilterLabel(MediaKind kind) => switch (kind) {
|
|
MediaKind.movie => t.libraries.groupings.movies,
|
|
MediaKind.show => t.libraries.groupings.shows,
|
|
MediaKind.season => t.libraries.groupings.seasons,
|
|
MediaKind.episode => t.libraries.groupings.episodes,
|
|
MediaKind.artist => t.libraries.groupings.artists,
|
|
MediaKind.album => t.libraries.groupings.albums,
|
|
MediaKind.track => t.libraries.groupings.tracks,
|
|
MediaKind.collection => t.libraries.tabs.collections,
|
|
MediaKind.playlist => t.libraries.tabs.playlists,
|
|
// Unreachable: excluded from _kindFilterOrder.
|
|
MediaKind.clip || MediaKind.photo || MediaKind.folder || MediaKind.unknown => kind.id,
|
|
};
|
|
|
|
Widget _buildKindFilterChips() {
|
|
// Chips trap RIGHT to keep focus inside the strip (see
|
|
// FocusableChipStateMixin), so left/right neighbors are wired explicitly,
|
|
// like every other tab-chip strip.
|
|
final kinds = <MediaKind?>[null, ..._candidateKinds];
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
child: TabChipStrip(
|
|
children: [
|
|
for (final (index, kind) in kinds.indexed) ...[
|
|
if (index > 0) const SizedBox(width: 8),
|
|
FocusableTabChip(
|
|
label: kind == null ? t.libraries.groupings.all : _kindFilterLabel(kind),
|
|
isSelected: _selectedKind == kind,
|
|
focusNode: _chipFocusNode(kind),
|
|
onSelect: () => _selectKindFilter(kind),
|
|
onNavigateLeft: index == 0 ? _navigateToSidebar : () => _chipFocusNode(kinds[index - 1]).requestFocus(),
|
|
onNavigateRight: index < kinds.length - 1
|
|
? () => _chipFocusNode(kinds[index + 1]).requestFocus()
|
|
: null,
|
|
onNavigateUp: focusSearchInput,
|
|
onNavigateDown: _focusFirstResult,
|
|
onBack: _navigateToSidebar,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResultsList(BuildContext context) {
|
|
final multiServer = context.watch<MultiServerProvider>();
|
|
final libraries = context.watch<LibrariesProvider>();
|
|
final showServerName = multiServer.totalServerCount > 1;
|
|
final visible = _visibleResults;
|
|
return buildResultsSliver(childCount: visible.length, (context, index) {
|
|
final item = visible[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 ? (_showKindChips ? _focusKindChips : focusSearchInput) : null,
|
|
showServerName: showServerName,
|
|
libraryName: libraries.libraryLabelFor(item),
|
|
);
|
|
});
|
|
}
|
|
|
|
@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
|
|
? (_showKindChips ? _focusKindChips : 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 ...[
|
|
if (_showKindChips) _buildKindFilterChips(),
|
|
_buildResultsList(context),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _SearchUnavailableException implements Exception {
|
|
const _SearchUnavailableException();
|
|
}
|