mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 06:15:28 +02:00
fix(search): let latest live queries supersede cancelled requests
This commit is contained in:
+12
-30
@@ -70,8 +70,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
late final ProviderSubscription<bool> _homeFeedExtSub;
|
||||
|
||||
Timer? _liveSearchDebounce;
|
||||
bool _isLiveSearchInProgress = false;
|
||||
String? _pendingLiveSearchQuery;
|
||||
int _searchGeneration = 0;
|
||||
static const int _minLiveSearchChars = 3;
|
||||
static const Duration _liveSearchDelay = Duration(milliseconds: 800);
|
||||
|
||||
@@ -396,40 +395,21 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
Future<void> _executeLiveSearch(String query) async {
|
||||
if (_isLiveSearchInProgress) {
|
||||
_pendingLiveSearchQuery = query;
|
||||
return;
|
||||
}
|
||||
|
||||
_isLiveSearchInProgress = true;
|
||||
_pendingLiveSearchQuery = null;
|
||||
|
||||
try {
|
||||
await _performSearch(query);
|
||||
} finally {
|
||||
_isLiveSearchInProgress = false;
|
||||
|
||||
final pending = _pendingLiveSearchQuery;
|
||||
_pendingLiveSearchQuery = null;
|
||||
|
||||
if (pending != null &&
|
||||
pending != query &&
|
||||
mounted &&
|
||||
_urlController.text.trim() == pending) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
if (mounted && _urlController.text.trim() == pending) {
|
||||
_executeLiveSearch(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mounted || _urlController.text.trim() != query) return;
|
||||
// The provider cancels the preceding native request and rejects stale
|
||||
// results. Let the latest debounced input reach that cancellation path.
|
||||
await _performSearch(query);
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
final generation = ++_searchGeneration;
|
||||
var extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized && extState.error == null) {
|
||||
await ref.read(extensionProvider.notifier).waitForInitialization();
|
||||
if (!mounted || generation != _searchGeneration) return;
|
||||
extState = ref.read(extensionProvider);
|
||||
}
|
||||
if (!mounted || generation != _searchGeneration) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = HomeSearchProviderPolicy.resolveProvider(
|
||||
@@ -501,7 +481,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
.read(trackProvider.notifier)
|
||||
.search(query, filterOverride: selectedFilter);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
@@ -524,7 +506,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_isResettingSearchSurface = true;
|
||||
try {
|
||||
_liveSearchDebounce?.cancel();
|
||||
_pendingLiveSearchQuery = null;
|
||||
_searchGeneration++;
|
||||
_lastSearchQuery = null;
|
||||
_activeSearchInput = null;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
@@ -824,7 +824,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
|
||||
|
||||
void _onSearchSubmitted() {
|
||||
_liveSearchDebounce?.cancel();
|
||||
_pendingLiveSearchQuery = null;
|
||||
_searchGeneration++;
|
||||
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
@@ -611,6 +611,9 @@ class PlatformBridge {
|
||||
}) {
|
||||
for (final entry in _customSearchInFlight.entries.toList()) {
|
||||
if (entry.key == exceptKey || entry.value.scopeKey != scopeKey) continue;
|
||||
// A cancelled request must not be reused if the user types its query
|
||||
// again before the native cancellation finishes.
|
||||
_customSearchInFlight.remove(entry.key);
|
||||
_cancelExtensionRequestUnawaited(entry.value.requestId);
|
||||
}
|
||||
}
|
||||
@@ -1824,7 +1827,8 @@ class PlatformBridge {
|
||||
'request_id': requestId,
|
||||
});
|
||||
final decoded = _decodeMapListResult(result, 'customSearchWithExtension');
|
||||
if (generation == _lookupCacheGeneration) {
|
||||
if (generation == _lookupCacheGeneration &&
|
||||
_customSearchInFlight[cacheKey]?.requestId == requestId) {
|
||||
_putMemoryCachedMapList(
|
||||
_customSearchCache,
|
||||
cacheKey,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
test(
|
||||
'latest search wins and A-B-A never reuses a cancelled request',
|
||||
() async {
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
final requests = <(String, String, Completer<String>)>[];
|
||||
final cancellations = <String>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
final arguments = call.arguments as Map;
|
||||
if (call.method == 'cancelExtensionRequest') {
|
||||
cancellations.add(arguments['request_id'] as String);
|
||||
return null;
|
||||
}
|
||||
if (call.method == 'customSearchWithExtension') {
|
||||
final result = Completer<String>();
|
||||
requests.add((
|
||||
arguments['query'] as String,
|
||||
arguments['request_id'] as String,
|
||||
result,
|
||||
));
|
||||
return result.future;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final notifier = container.read(trackProvider.notifier);
|
||||
final first = notifier.customSearch('regression-search-provider', 'A');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final second = notifier.customSearch('regression-search-provider', 'B');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final third = notifier.customSearch('regression-search-provider', 'A');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(requests.map((r) => r.$1), ['A', 'B', 'A']);
|
||||
expect(cancellations, containsAll([requests[0].$2, requests[1].$2]));
|
||||
String response(String id) => jsonEncode([
|
||||
{
|
||||
'id': id,
|
||||
'name': id,
|
||||
'artist_name': 'Artist',
|
||||
'album_name': 'Album',
|
||||
'duration': 1000,
|
||||
},
|
||||
]);
|
||||
requests[2].$3.complete(response('latest'));
|
||||
await third;
|
||||
requests[0].$3.complete(response('old-A'));
|
||||
requests[1].$3.complete(response('old-B'));
|
||||
await Future.wait([first, second]);
|
||||
expect(container.read(trackProvider).tracks.single.id, 'latest');
|
||||
expect(container.read(trackProvider).isLoading, isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user