Consolidates duplicated logic behind shared implementations — paginated grid tabs, focus chrome, cached remote stores, sheet selection columns, the server artifact store and a test fixture layer — and removes code that had become unreachable. Net reduction of about 5,500 lines with no behaviour change. Where a fix had landed separately in code that moved into a shared helper, the fix was re-applied inside the helper rather than left behind in the copy that went away.
206 lines
7.0 KiB
Dart
206 lines
7.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import '../exceptions/media_server_exceptions.dart';
|
|
|
|
import '../media/media_item.dart';
|
|
import '../utils/app_logger.dart';
|
|
|
|
/// Debounced free-text media search shared by the main search screen and the
|
|
/// catalog (Explore) search screen: text controller + focus nodes, a 500ms
|
|
/// debounce, a generation guard against out-of-order responses, in-flight
|
|
/// invalidation when the text diverges from the query being fetched, and the
|
|
/// loading/failed/empty state flags the screens render from.
|
|
///
|
|
/// Implementations override [performSearchQuery]; everything else (including
|
|
/// controller/node disposal) is owned here.
|
|
mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
|
static const Duration searchDebounceDuration = Duration(milliseconds: 500);
|
|
|
|
late final TextEditingController searchController = TextEditingController();
|
|
late final FocusNode searchFocusNode = FocusNode(debugLabel: '${searchDebugLabel}Input');
|
|
late final FocusNode firstResultFocusNode = FocusNode(debugLabel: '${searchDebugLabel}FirstResult');
|
|
|
|
/// Plain restartable timer instead of rate_limiter's Debounce: that one
|
|
/// times its trailing edge with DateTime.now(), which never advances under
|
|
/// the widget-test fake clock, so the debounce would be untestable.
|
|
Timer? _debounceTimer;
|
|
|
|
List<MediaItem> searchResults = [];
|
|
bool isSearching = false;
|
|
bool hasSearched = false;
|
|
bool lastSearchFailed = false;
|
|
String lastSearchedQuery = '';
|
|
|
|
int _searchGeneration = 0;
|
|
String? _inFlightQuery;
|
|
bool _showedClearButton = false;
|
|
String _lastObservedText = '';
|
|
|
|
/// Names the focus nodes and log lines.
|
|
String get searchDebugLabel => widget.runtimeType.toString();
|
|
|
|
/// Run the actual search. Non-cancellation errors flip [lastSearchFailed].
|
|
Future<List<MediaItem>> performSearchQuery(String query);
|
|
|
|
/// A failed search was applied to the state (e.g. show a snackbar).
|
|
void onSearchError(Object error) {}
|
|
|
|
/// A successful search was applied to the state.
|
|
void onSearchCompleted(String query, List<MediaItem> results) {}
|
|
|
|
/// The field was cleared and the state reset.
|
|
void onSearchCleared() {}
|
|
|
|
/// The active query was superseded or the search scope is being disposed.
|
|
/// Implementations may cancel transport work here; the generation guard
|
|
/// remains authoritative for preventing stale UI commits.
|
|
void onSearchInvalidated() {}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
searchController.addListener(_onSearchTextChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounceTimer?.cancel();
|
|
onSearchInvalidated();
|
|
searchController.removeListener(_onSearchTextChanged);
|
|
searchController.dispose();
|
|
searchFocusNode.dispose();
|
|
firstResultFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onSearchTextChanged() {
|
|
if (!mounted) return;
|
|
// The controller also notifies on selection/composing changes (e.g. the
|
|
// focus gain after an external text set writes a collapsed selection); a
|
|
// selection-only notification mid-flight would re-arm the debounce and
|
|
// re-run the identical query against the servers.
|
|
final text = searchController.text;
|
|
if (text == _lastObservedText) return;
|
|
_lastObservedText = text;
|
|
final query = text.trim();
|
|
|
|
// The clear affordance tracks text emptiness; without this rebuild it
|
|
// only appeared when a search landed ~500ms later.
|
|
if (query.isNotEmpty != _showedClearButton) {
|
|
_showedClearButton = query.isNotEmpty;
|
|
setState(() {});
|
|
}
|
|
|
|
if (query.isEmpty) {
|
|
_debounceTimer?.cancel();
|
|
_searchGeneration++;
|
|
if (_inFlightQuery != null) onSearchInvalidated();
|
|
_inFlightQuery = null;
|
|
setState(() {
|
|
searchResults = [];
|
|
hasSearched = false;
|
|
isSearching = false;
|
|
lastSearchFailed = false;
|
|
lastSearchedQuery = '';
|
|
});
|
|
onSearchCleared();
|
|
return;
|
|
}
|
|
|
|
if (query == lastSearchedQuery) {
|
|
// Reverted to what's already shown: the pending debounce and any
|
|
// in-flight pass for the intermediate text must not land afterwards.
|
|
_debounceTimer?.cancel();
|
|
if (_invalidateStaleInFlight(query)) setState(() => isSearching = false);
|
|
return;
|
|
}
|
|
|
|
_invalidateStaleInFlight(query);
|
|
_debounceTimer?.cancel();
|
|
_debounceTimer = Timer(searchDebounceDuration, () => runSearch(query));
|
|
}
|
|
|
|
/// An in-flight search for text the field no longer shows can only land
|
|
/// wrong; kill it via the generation. Returns true when one was dropped.
|
|
bool _invalidateStaleInFlight(String current) {
|
|
if (_inFlightQuery == null || _inFlightQuery == current) return false;
|
|
_searchGeneration++;
|
|
onSearchInvalidated();
|
|
_inFlightQuery = null;
|
|
return true;
|
|
}
|
|
|
|
/// Run [query] now, bypassing the debounce (submit, external refresh).
|
|
Future<void> runSearch(String query) async {
|
|
if (!mounted || query.isEmpty) return;
|
|
if (_inFlightQuery != null) onSearchInvalidated();
|
|
final generation = ++_searchGeneration;
|
|
_inFlightQuery = query;
|
|
setState(() {
|
|
isSearching = true;
|
|
hasSearched = true;
|
|
lastSearchFailed = false;
|
|
});
|
|
try {
|
|
final results = await performSearchQuery(query);
|
|
if (!mounted || generation != _searchGeneration) return;
|
|
_inFlightQuery = null;
|
|
setState(() {
|
|
searchResults = results;
|
|
isSearching = false;
|
|
lastSearchedQuery = query;
|
|
});
|
|
onSearchCompleted(query, results);
|
|
} catch (e) {
|
|
if (!mounted || generation != _searchGeneration) return;
|
|
if (e is MediaServerHttpException && e.isCancellation) {
|
|
_inFlightQuery = null;
|
|
setState(() => isSearching = false);
|
|
return;
|
|
}
|
|
appLogger.w('$searchDebugLabel: search failed', error: e);
|
|
_inFlightQuery = null;
|
|
setState(() {
|
|
searchResults = [];
|
|
isSearching = false;
|
|
lastSearchFailed = true;
|
|
lastSearchedQuery = query;
|
|
});
|
|
onSearchError(e);
|
|
}
|
|
}
|
|
|
|
/// The results list both screens render: padded, without keep-alives or
|
|
/// semantic indexes, one child per entry of [searchResults].
|
|
Widget buildResultsSliver(NullableIndexedWidgetBuilder itemBuilder) {
|
|
return SliverPadding(
|
|
padding: const EdgeInsets.all(16),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
itemBuilder,
|
|
childCount: searchResults.length,
|
|
addAutomaticKeepAlives: false,
|
|
addSemanticIndexes: false,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// OSK "Search" / hardware Enter on TV: jump to results, or force the
|
|
/// pending search to run now.
|
|
void handleSearchSubmit() {
|
|
final query = searchController.text.trim();
|
|
if (query.isEmpty) return;
|
|
if (searchResults.isNotEmpty && !isSearching && query == lastSearchedQuery) {
|
|
firstResultFocusNode.requestFocus();
|
|
return;
|
|
}
|
|
if ((_debounceTimer?.isActive ?? false) || !isSearching) {
|
|
_debounceTimer?.cancel();
|
|
runSearch(query);
|
|
}
|
|
// else: the in-flight search already covers the current text.
|
|
}
|
|
}
|