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
341 lines
12 KiB
Dart
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();
|
|
}
|