@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -39,7 +40,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
/// Names the focus nodes and log lines.
|
||||
String get searchDebugLabel => widget.runtimeType.toString();
|
||||
|
||||
/// Run the actual search. Thrown errors flip [lastSearchFailed].
|
||||
/// 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).
|
||||
@@ -51,6 +52,11 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
/// 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();
|
||||
@@ -60,6 +66,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
onSearchInvalidated();
|
||||
searchController.removeListener(_onSearchTextChanged);
|
||||
searchController.dispose();
|
||||
searchFocusNode.dispose();
|
||||
@@ -88,6 +95,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
if (query.isEmpty) {
|
||||
_debounceTimer?.cancel();
|
||||
_searchGeneration++;
|
||||
if (_inFlightQuery != null) onSearchInvalidated();
|
||||
_inFlightQuery = null;
|
||||
setState(() {
|
||||
searchResults = [];
|
||||
@@ -118,6 +126,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
bool _invalidateStaleInFlight(String current) {
|
||||
if (_inFlightQuery == null || _inFlightQuery == current) return false;
|
||||
_searchGeneration++;
|
||||
onSearchInvalidated();
|
||||
_inFlightQuery = null;
|
||||
return true;
|
||||
}
|
||||
@@ -125,6 +134,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
/// 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(() {
|
||||
@@ -143,8 +153,13 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
});
|
||||
onSearchCompleted(query, results);
|
||||
} catch (e) {
|
||||
appLogger.w('$searchDebugLabel: search failed', error: 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 = [];
|
||||
|
||||
Reference in New Issue
Block a user