Files
plezy/lib/screens/search_screen.dart
T
edde746 1d9ffb7427 fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a
hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue
Watching and the library rail still surfaced their contents in the Search
tab. Thread the profile's hidden keys from SearchScreen through to the
aggregation, and drop matching items between the fan-out and the ranking
pass so hidden hits cannot spend the result limit and shrink what is
shown. Items the backend cannot attribute to a library, such as Plex
shared and external media, are kept.

The screen re-runs the visible query when a library is hidden or unhidden
while results are on screen. Its listener is attached only after the
provider has hydrated, so the initial load notification cannot race the
first query into running twice.

Plex search rows now go through the library-aware tagger, so a response
that names its section only via librarySectionKey or
targetLibrarySectionID is still attributable, and therefore filterable.

Jellyfin search results carry no library id at all: the mapper's
ParentLibraryId is not a Jellyfin field, and ParentId resolves to a
season or physical folder rather than a CollectionFolder. Filtering there
needs server-side ParentId scoping and is left for a follow-up.

close #1770
2026-08-02 09:29:59 +02:00

341 lines
12 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 '../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<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;
HiddenLibrariesProvider? _hiddenLibraries;
Set<String> _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<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;
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<MediaItem> 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<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;
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<MultiServerProvider>();
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();
}