fix(media): serialize browsing and metadata mutations

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent 43a8fe020d
commit f8bfecf57d
30 changed files with 3088 additions and 465 deletions
+84 -19
View File
@@ -4,6 +4,8 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../focus/input_mode_tracker.dart';
import '../../media/media_filter.dart';
import 'state_messages.dart';
import '../../utils/app_logger.dart';
import '../../utils/scroll_utils.dart';
import '../../widgets/bottom_sheet_page_scaffold.dart';
import '../../widgets/focusable_list_tile.dart';
@@ -47,6 +49,8 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
MediaFilter? _currentFilter;
List<MediaFilterValue> _filterValues = [];
bool _isLoadingValues = false;
String? _filterValuesError;
int _filterValuesLoadGeneration = 0;
final Map<String, String> _tempSelectedFilters = {};
static final Map<String, String> _filterDisplayNames = {}; // Cache for display names
static const int _maxCachedDisplayNames = 1000;
@@ -65,8 +69,28 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
_initialFocusNode = FocusNode(debugLabel: 'FiltersBottomSheetInitialFocus');
}
@override
void didUpdateWidget(covariant FiltersBottomSheet oldWidget) {
super.didUpdateWidget(oldWidget);
final ownerChanged = oldWidget.serverId != widget.serverId || oldWidget.libraryKey != widget.libraryKey;
if (ownerChanged) {
_filterValuesLoadGeneration++;
_currentFilter = null;
_filterValues = [];
_isLoadingValues = false;
_filterValuesError = null;
_tempSelectedFilters
..clear()
..addAll(widget.selectedFilters);
}
if (ownerChanged || !identical(oldWidget.filters, widget.filters)) {
_sortFilters();
}
}
@override
void dispose() {
_filterValuesLoadGeneration++;
_valuesScrollController.dispose();
_initialFocusNode.dispose();
super.dispose();
@@ -86,52 +110,80 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
Future<void> _loadFilterValues(MediaFilter filter) async {
final generation = ++_filterValuesLoadGeneration;
final filterKey = filter.filter;
final serverId = widget.serverId;
final libraryKey = widget.libraryKey;
final cachedValues = widget.cachedValues;
final loader = widget.loadFilterValues;
setState(() {
_currentFilter = filter;
_filterValues = [];
_isLoadingValues = true;
_filterValuesError = null;
});
try {
// Cached path (Jellyfin) - `/Items/Filters` returned values inline.
final cached = widget.cachedValues?[filter.filter];
final values = cached ?? await widget.loadFilterValues(filter);
if (!mounted) return;
final cached = cachedValues?[filterKey];
final values = cached ?? await loader(filter);
if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return;
final selectedValue = _tempSelectedFilters[filterKey];
final selectedIndex = selectedValue == null
? -1
: values.indexWhere((value) => _extractFilterValue(value.key, filterKey) == selectedValue);
setState(() {
_filterValues = values;
_isLoadingValues = false;
});
_requestInitialFocus();
// Scroll to selected value if any
final selectedValue = _tempSelectedFilters[filter.filter];
if (selectedValue != null) {
// +1 because index 0 is the "All" row
final idx = values.indexWhere((v) => _extractFilterValue(v.key, filter.filter) == selectedValue) + 1;
if (idx > 0) {
scrollToCurrentItem(_valuesScrollController, _valuesFirstItemKey, idx);
}
_requestInitialFocus(generation, serverId, libraryKey, filterKey);
if (selectedIndex >= 0) {
// +1 because index 0 is the "All" row.
scrollToCurrentItem(
_valuesScrollController,
_valuesFirstItemKey,
selectedIndex + 1,
isCurrent: () => _isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey),
);
}
} catch (e) {
if (!mounted) return;
} catch (e, stackTrace) {
if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return;
appLogger.w('Failed to load values for filter $filterKey', error: e, stackTrace: stackTrace);
setState(() {
_filterValues = [];
_isLoadingValues = false;
_filterValuesError = t.errors.unableToLoad(context: filter.title);
});
_requestInitialFocus();
_requestInitialFocus(generation, serverId, libraryKey, filterKey);
}
}
bool _isCurrentFilterValuesLoad(int generation, String serverId, String libraryKey, String? filterKey) {
return mounted &&
generation == _filterValuesLoadGeneration &&
widget.serverId == serverId &&
widget.libraryKey == libraryKey &&
_currentFilter?.filter == filterKey;
}
void _goBack() {
final generation = ++_filterValuesLoadGeneration;
final serverId = widget.serverId;
final libraryKey = widget.libraryKey;
setState(() {
_currentFilter = null;
_filterValues = [];
_isLoadingValues = false;
_filterValuesError = null;
});
_requestInitialFocus();
_requestInitialFocus(generation, serverId, libraryKey, null);
}
void _requestInitialFocus() {
void _requestInitialFocus(int generation, String serverId, String libraryKey, String? filterKey) {
if (!InputModeTracker.isKeyboardMode(context)) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return;
if (_initialFocusNode.context != null) {
_initialFocusNode.requestFocus();
} else {
@@ -141,6 +193,7 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
void _clearFilters() {
_filterValuesLoadGeneration++;
setState(() {
_tempSelectedFilters.clear();
});
@@ -148,7 +201,8 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
void _applyFilters() {
widget.onFiltersChanged(_tempSelectedFilters);
_filterValuesLoadGeneration++;
widget.onFiltersChanged(Map<String, String>.of(_tempSelectedFilters));
OverlaySheetController.of(context).close();
}
@@ -186,6 +240,17 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
Widget _buildFilterValuesView(MediaFilter filter) {
final error = _filterValuesError;
if (error != null) {
return ErrorStateWidget(
message: error,
onRetry: () => _loadFilterValues(filter),
actionFocusNode: _initialFocusNode,
onActionBack: _goBack,
actionAutofocus: InputModeTracker.isKeyboardMode(context),
actionUseBackgroundFocus: true,
);
}
if (_isLoadingValues) {
return Focus(
autofocus: InputModeTracker.isKeyboardMode(context),
+3 -2
View File
@@ -485,14 +485,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Save selected library key and restore saved tab (async — safe after state is consistent)
final storage = await StorageService.getInstance();
if (!mounted) return;
if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return;
await storage.saveSelectedLibraryKey(libraryGlobalKey);
if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return;
// Restore saved tab by name
final savedTabName = storage.getLibraryTab(libraryGlobalKey);
final savedType = LibraryTabType.values.where((t) => t.name == savedTabName).firstOrNull;
final targetTabIndex = savedType != null ? _visibleTabs.indexOf(savedType) : -1;
if (targetTabIndex > 0) {
if (targetTabIndex >= 0 && targetTabIndex != tabController.index) {
// Set flag to prevent _onTabChanged from triggering focus
_isRestoringTab = true;
// Use animateTo with zero duration for instant switch without animation race conditions
@@ -74,6 +74,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _refreshSubscription;
int _loadGeneration = 0;
// Focus management
bool _hasLoadedData = false;
@@ -97,6 +98,22 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
@protected
set hasLoadedData(bool value) => _hasLoadedData = value;
@protected
int get libraryLoadGeneration => _loadGeneration;
@protected
int beginLibraryLoad() => ++_loadGeneration;
@protected
void invalidateLibraryLoad() {
_loadGeneration++;
}
@protected
bool isCurrentLibraryLoad(int generation, String libraryGlobalKey) {
return mounted && generation == _loadGeneration && widget.library.globalKey == libraryGlobalKey;
}
@override
void initState() {
super.initState();
@@ -115,6 +132,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
@override
void dispose() {
invalidateLibraryLoad();
_refreshSubscription?.cancel();
super.dispose();
}
@@ -124,6 +142,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
invalidateLibraryLoad();
// Reset focus state for new library
hasFocused = false;
_hasFocusedChromeFallback = false;
@@ -173,12 +192,14 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
if (!widget.isActive || !_hasLoadedData) return;
final loadGeneration = _loadGeneration;
final libraryGlobalKey = widget.library.globalKey;
if (hasFocusableContent) {
_hasFocusedChromeFallback = false;
if (hasFocused) return;
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
focusFirstItem();
}
});
@@ -188,7 +209,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
if (!_hasFocusedChromeFallback) {
_hasFocusedChromeFallback = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
focusEmptyState();
}
});
@@ -220,6 +241,10 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
/// Load items with error handling and state management
Future<void> loadItems() async {
if (!mounted) return;
final loadGeneration = beginLibraryLoad();
final libraryGlobalKey = widget.library.globalKey;
setState(() {
_isLoading = true;
_errorMessage = null;
@@ -228,27 +253,28 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
try {
final loadedItems = await loadData();
if (!mounted) return;
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
setState(() {
_items = loadedItems;
_isLoading = false;
_hasLoadedData = true;
});
// Mark data as loaded and try to focus
_hasLoadedData = true;
tryFocus();
// Notify parent that data has loaded
if (widget.onDataLoaded != null) {
final onDataLoaded = widget.onDataLoaded;
if (onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
onDataLoaded();
}
});
}
} catch (e, stackTrace) {
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext);
if (!mounted) return;
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
setState(() {
_errorMessage = message;
@@ -9,6 +9,7 @@ import '../../../media/library_query.dart';
import '../../../media/media_backend.dart';
import '../../../media/media_item.dart';
import '../../../media/media_kind.dart';
import '../../../media/media_library.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../focus/dpad_navigator.dart';
@@ -279,10 +280,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
DateTime? _lastAlphaUpdate;
Timer? _alphaUpdateTimer;
/// Generation counter for the filter/sort loading phase of [_loadContent].
/// Separate from the mixin's pagination generation so a filter reload can
/// invalidate in-flight filter/sort fetches without touching item pagination.
int _contentRequestId = 0;
int _firstCharactersRequestId = 0;
static const int _fetchSize = 200;
static const int _jellyfinFetchSize = 72;
@@ -323,8 +320,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final normalized = _normalizeGrouping(_selectedGrouping);
if (normalized != _selectedGrouping) {
_selectedGrouping = normalized;
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
unawaited(_loadItems());
unawaited(_loadFirstCharacters());
});
@@ -385,9 +384,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// from interfering with TabBarView page animations
if (!InputModeTracker.isKeyboardMode(context)) return;
if (widget.isActive && hasLoadedData && !hasFocused) {
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusContentOrChrome();
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
focusContentOrChrome();
}
});
}
}
@@ -426,10 +429,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Focus the first item in the grid/list/folder tree (for tab activation)
@override
void focusFirstItem() {
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
// In folder mode, items list is empty — focus the first folder tree item directly
if (_selectedGrouping == 'folders') {
void request() {
if (mounted && !firstItemFocusNode.hasFocus) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey) && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -443,7 +449,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt.
void request() {
if (mounted && (loadedItems.isNotEmpty || _hasFocusableStateAction) && !firstItemFocusNode.hasFocus) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey) &&
(loadedItems.isNotEmpty || _hasFocusableStateAction) &&
!firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -524,11 +532,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
Future<void> _loadContent() async {
final generation = ++_contentRequestId;
if (!mounted) return;
final library = widget.library;
final libraryGlobalKey = library.globalKey;
final generation = beginLibraryLoad();
final firstCharactersGeneration = ++_firstCharactersRequestId;
_resetForFullReload();
_resetTopOfPageState();
_currentFirstVisibleIndex.value = 0;
@@ -536,34 +546,35 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// `/sorts`; Jellyfin maps `/Items/Filters` into the same shape with
// values pre-cached and a hardcoded client-side sort list. Both flow
// through the unified [MediaServerClient.fetchLibraryFiltersWithValues].
try {
final client = context.getMediaClientForLibrary(widget.library);
final client = context.getMediaClientForLibrary(library);
final loader = LibraryFilterSortLoader(clientFor: (_) => client);
final storage = await StorageService.getInstance();
final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey);
final savedSort = storage.getLibrarySort(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKey);
final savedSort = storage.getLibrarySort(libraryGlobalKey);
final savedGrouping = storage.getLibraryGrouping(libraryGlobalKey);
// Resolve the restored grouping before the sort fetch — music groupings
// (albums/tracks) request their own per-type sort list.
final restoredGrouping = _normalizeGrouping(savedGrouping);
final sortLibraryType = _sortOptionsLibraryType(restoredGrouping);
final LoadedFiltersAndSorts loaded;
if (_isJellyfinLibrary) {
if (library.backend == MediaBackend.jellyfin) {
// `/Items/Filters` can be much slower than the paged `/Items` browse
// request on large Jellyfin libraries. Load only the local sort list
// before page 1, then fill filter values in the background.
final sorts = await client.fetchSortOptions(widget.library.id, libraryType: sortLibraryType);
final sorts = await client.fetchSortOptions(library.id, libraryType: sortLibraryType);
loaded = LoadedFiltersAndSorts(filters: const [], sorts: sorts);
} else {
// Plex filters+sorts must resolve before items so saved-sort restoration
// can match a saved key against the just-loaded sort list, and so the
// first item fetch already includes the restored sort param.
loaded = await loader.load(widget.library, sortLibraryType: sortLibraryType);
loaded = await loader.load(library, sortLibraryType: sortLibraryType);
}
if (generation != _contentRequestId || !mounted) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
setState(() {
_filters = loaded.filters;
_sortOptions = loaded.sorts;
@@ -587,16 +598,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
});
_notifyFiltersActive();
if (_isJellyfinLibrary) {
_loadJellyfinFiltersInBackground(generation);
if (library.backend == MediaBackend.jellyfin) {
_loadJellyfinFiltersInBackground(generation, libraryGlobalKey, library);
}
// Load items and first characters in parallel
// _loadItems manages its own requestId internally
await Future.wait([_loadItems(), _loadFirstCharacters(requestId: firstCharactersGeneration)]);
// Load items and first characters in parallel.
await Future.wait([
_loadItems(loadGeneration: generation, libraryGlobalKey: libraryGlobalKey),
_loadFirstCharacters(requestId: firstCharactersGeneration),
]);
} catch (e, stackTrace) {
if (!mounted) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
setState(() {
errorMessage = message;
isLoading = false;
@@ -604,20 +618,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
}
void _loadJellyfinFiltersInBackground(int generation) {
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
void _loadJellyfinFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
final client = context.tryGetMediaClientForServer(serverIdOrNull(library.serverId));
if (client == null) return;
unawaited(
client
.fetchLibraryFiltersWithValues(widget.library.id, libraryKind: widget.library.kind)
.fetchLibraryFiltersWithValues(library.id, libraryKind: library.kind)
.then((result) {
if (generation != _contentRequestId || !mounted) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
setState(() {
_filters = result.filters;
_jellyfinFilterValues = result.cachedValues;
});
})
.catchError((Object e, StackTrace st) {
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
appLogger.w('Jellyfin library filters failed; browse content remains available', error: e, stackTrace: st);
}),
);
@@ -630,8 +645,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final cb = widget.onFiltersActiveChanged;
if (cb == null) return;
final active = _selectedFilters.isNotEmpty;
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) cb(active);
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) cb(active);
});
}
@@ -690,8 +707,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return filterParams;
}
Future<void> _loadItems({bool preserveFocus = false}) async {
final generation = _contentRequestId;
Future<void> _loadItems({bool preserveFocus = false, int? loadGeneration, String? libraryGlobalKey}) async {
final generation = loadGeneration ?? libraryLoadGeneration;
final acceptedLibraryGlobalKey = libraryGlobalKey ?? widget.library.globalKey;
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
setState(() {
isLoading = true;
items = [];
@@ -707,7 +726,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
try {
final initialPage = await loadInitialPageWithStatus(_calculateInitialFetchSize());
if (!initialPage.applied || generation != _contentRequestId || !mounted) return;
if (!initialPage.applied || !isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
setState(() {
isLoading = false;
});
@@ -717,15 +736,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
tryFocus();
}
// Notify parent
if (!preserveFocus && widget.onDataLoaded != null) {
// Notify parent after the accepted library remains current for a frame.
final onDataLoaded = widget.onDataLoaded;
if (!preserveFocus && onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
if (isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) {
onDataLoaded();
}
});
}
} catch (e, stackTrace) {
if (generation != _contentRequestId || !mounted) return;
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
setState(() {
errorMessage = message;
isLoading = false;
@@ -961,16 +984,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// load items. Only called when the grouping switch changed the sort type
/// (artist/album/track on music libraries).
Future<void> _reloadSortOptionsForGrouping() async {
final generation = _contentRequestId;
final generation = libraryLoadGeneration;
final library = widget.library;
final libraryGlobalKey = library.globalKey;
final grouping = _selectedGrouping;
var sorts = const <MediaSort>[];
try {
final client = context.getMediaClientForLibrary(widget.library);
sorts = await client.fetchSortOptions(widget.library.id, libraryType: _sortOptionsLibraryType(grouping));
final client = context.getMediaClientForLibrary(library);
sorts = await client.fetchSortOptions(library.id, libraryType: _sortOptionsLibraryType(grouping));
} catch (e, st) {
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
appLogger.w('Failed to load sort options for grouping $grouping', error: e, stackTrace: st);
}
if (!mounted || generation != _contentRequestId || grouping != _selectedGrouping) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey) || grouping != _selectedGrouping) return;
setState(() {
_sortOptions = sorts;
if (_selectedSort != null && sorts.every((s) => s.key != _selectedSort!.key)) {