fix(media): serialize browsing and metadata mutations
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user