Files
plezy/lib/screens/search_screen.dart
edde746 4f4a7338e0 fix(search): tighten spacing around the kind filter chip strip
The chip strip sat 24px below the search field and 24px above the first
result. Drop the strip's own top padding (the field's bottom padding
already provides 16px) and halve the results sliver's top padding when
the strip is shown, so both gaps read as an even 16px.
2026-08-31 06:01:48 +02:00

522 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(
// The search field's own bottom padding provides the gap above; the
// results sliver below shrinks its top padding to match (16/16 visual
// rhythm around the strip instead of the default 24/24).
padding: const EdgeInsets.symmetric(horizontal: 16),
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,
// Half the default top padding when the chip strip sits directly above:
// the strip already separates results from the search field.
padding: _showKindChips ? const EdgeInsets.fromLTRB(16, 8, 16, 16) : const EdgeInsets.all(16),
(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();
}