|
|
@@ -18,6 +18,7 @@ import '../../../widgets/alpha_jump_bar.dart';
|
|
|
|
import '../../../widgets/alpha_jump_helper.dart';
|
|
|
|
import '../../../widgets/alpha_jump_helper.dart';
|
|
|
|
import '../../../widgets/alpha_scroll_handle.dart';
|
|
|
|
import '../../../widgets/alpha_scroll_handle.dart';
|
|
|
|
import '../../../widgets/focusable_media_card.dart';
|
|
|
|
import '../../../widgets/focusable_media_card.dart';
|
|
|
|
|
|
|
|
import '../../../widgets/media_card.dart';
|
|
|
|
import '../../../widgets/focusable_filter_chip.dart';
|
|
|
|
import '../../../widgets/focusable_filter_chip.dart';
|
|
|
|
import '../../../widgets/media_grid_delegate.dart';
|
|
|
|
import '../../../widgets/media_grid_delegate.dart';
|
|
|
|
import '../../../widgets/overlay_sheet.dart';
|
|
|
|
import '../../../widgets/overlay_sheet.dart';
|
|
|
@@ -68,14 +69,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
String? get deletionServerId => widget.library.serverId;
|
|
|
|
String? get deletionServerId => widget.library.serverId;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
Set<String>? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet();
|
|
|
|
Set<String>? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet();
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
Set<String>? get deletionGlobalKeys {
|
|
|
|
Set<String>? get deletionGlobalKeys {
|
|
|
|
if (items.isEmpty) return <String>{};
|
|
|
|
if (_loadedItems.isEmpty) return <String>{};
|
|
|
|
|
|
|
|
|
|
|
|
final keys = <String>{};
|
|
|
|
final keys = <String>{};
|
|
|
|
for (final item in items) {
|
|
|
|
for (final item in _loadedItems.values) {
|
|
|
|
final serverId = item.serverId ?? widget.library.serverId;
|
|
|
|
final serverId = item.serverId ?? widget.library.serverId;
|
|
|
|
if (serverId == null) return null;
|
|
|
|
if (serverId == null) return null;
|
|
|
|
keys.add(_toGlobalKey(item.ratingKey, serverId: serverId));
|
|
|
|
keys.add(_toGlobalKey(item.ratingKey, serverId: serverId));
|
|
|
@@ -85,30 +86,30 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
void onDeletionEvent(DeletionEvent event) {
|
|
|
|
void onDeletionEvent(DeletionEvent event) {
|
|
|
|
// If we have an item that matches the rating key exactly, then remove it from our list
|
|
|
|
// If we have an item that matches the rating key exactly, remove it and rebuild indices
|
|
|
|
final index = items.indexWhere((e) => e.ratingKey == event.ratingKey);
|
|
|
|
final matchEntry = _loadedItems.entries.where((e) => e.value.ratingKey == event.ratingKey).firstOrNull;
|
|
|
|
if (index != -1) {
|
|
|
|
if (matchEntry != null) {
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
items.removeAt(index);
|
|
|
|
_removeLoadedItemAndShift(matchEntry.key);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If a child item was delete, then update our list to reflect that.
|
|
|
|
// If a child item was deleted, update our item to reflect that.
|
|
|
|
// If all children were deleted, remove our item.
|
|
|
|
// If all children were deleted, remove our item.
|
|
|
|
// Otherwise, just update the counts.
|
|
|
|
// Otherwise, just update the counts.
|
|
|
|
for (final parentKey in event.parentChain) {
|
|
|
|
for (final parentKey in event.parentChain) {
|
|
|
|
final parentIndex = items.indexWhere((e) => e.ratingKey == parentKey);
|
|
|
|
final parentEntry = _loadedItems.entries.where((e) => e.value.ratingKey == parentKey).firstOrNull;
|
|
|
|
if (parentIndex != -1) {
|
|
|
|
if (parentEntry != null) {
|
|
|
|
final item = items[parentIndex];
|
|
|
|
final item = parentEntry.value;
|
|
|
|
final newLeafCount = (item.leafCount ?? 1) - event.leafCount;
|
|
|
|
final newLeafCount = (item.leafCount ?? 1) - event.leafCount;
|
|
|
|
if (newLeafCount <= 0) {
|
|
|
|
if (newLeafCount <= 0) {
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
items.removeAt(parentIndex);
|
|
|
|
_removeLoadedItemAndShift(parentEntry.key);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
items[parentIndex] = item.copyWith(leafCount: newLeafCount);
|
|
|
|
_loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
return;
|
|
|
@@ -116,18 +117,37 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Remove an item at [index] and shift all higher indices down by 1
|
|
|
|
|
|
|
|
void _removeLoadedItemAndShift(int index) {
|
|
|
|
|
|
|
|
_loadedItems.remove(index);
|
|
|
|
|
|
|
|
// Rebuild map with shifted indices for items above the removed one
|
|
|
|
|
|
|
|
final shifted = <int, PlexMetadata>{};
|
|
|
|
|
|
|
|
for (final entry in _loadedItems.entries) {
|
|
|
|
|
|
|
|
if (entry.key < index) {
|
|
|
|
|
|
|
|
shifted[entry.key] = entry.value;
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
shifted[entry.key - 1] = entry.value;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
_loadedItems.clear();
|
|
|
|
|
|
|
|
_loadedItems.addAll(shifted);
|
|
|
|
|
|
|
|
_totalSize = (_totalSize - 1).clamp(0, _totalSize);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
String get focusNodeDebugLabel => 'browse_first_item';
|
|
|
|
String get focusNodeDebugLabel => 'browse_first_item';
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
int get itemCount => items.length;
|
|
|
|
int get itemCount => _totalSize;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
|
|
|
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
final index = items.indexWhere((item) => item.ratingKey == ratingKey);
|
|
|
|
for (final entry in _loadedItems.entries) {
|
|
|
|
if (index != -1) {
|
|
|
|
if (entry.value.ratingKey == ratingKey) {
|
|
|
|
items[index] = updatedMetadata;
|
|
|
|
_loadedItems[entry.key] = updatedMetadata;
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -162,11 +182,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
Timer? _scrollActivityTimer;
|
|
|
|
Timer? _scrollActivityTimer;
|
|
|
|
|
|
|
|
|
|
|
|
// Pagination state
|
|
|
|
// Pagination state
|
|
|
|
int _currentPage = 0;
|
|
|
|
int _totalSize = 0;
|
|
|
|
bool _hasMoreItems = true;
|
|
|
|
final Map<int, PlexMetadata> _loadedItems = {};
|
|
|
|
|
|
|
|
final Set<int> _loadingRanges = {};
|
|
|
|
CancelToken? _cancelToken;
|
|
|
|
CancelToken? _cancelToken;
|
|
|
|
int _requestId = 0;
|
|
|
|
int _requestId = 0;
|
|
|
|
static const int _pageSize = 500;
|
|
|
|
static const int _fetchSize = 200;
|
|
|
|
|
|
|
|
Timer? _scrollIdleTimer;
|
|
|
|
|
|
|
|
|
|
|
|
// Focus nodes for filter chips
|
|
|
|
// Focus nodes for filter chips
|
|
|
|
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
|
|
|
|
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
|
|
|
@@ -186,6 +208,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
void dispose() {
|
|
|
|
void dispose() {
|
|
|
|
_cancelToken?.cancel();
|
|
|
|
_cancelToken?.cancel();
|
|
|
|
_scrollActivityTimer?.cancel();
|
|
|
|
_scrollActivityTimer?.cancel();
|
|
|
|
|
|
|
|
_scrollIdleTimer?.cancel();
|
|
|
|
_scrollController.removeListener(_onScrollChanged);
|
|
|
|
_scrollController.removeListener(_onScrollChanged);
|
|
|
|
_scrollController.dispose();
|
|
|
|
_scrollController.dispose();
|
|
|
|
_groupingChipFocusNode.dispose();
|
|
|
|
_groupingChipFocusNode.dispose();
|
|
|
@@ -196,6 +219,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
super.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Override tryFocus to use _loadedItems instead of base class items list
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
|
|
void tryFocus() {
|
|
|
|
|
|
|
|
if (widget.suppressAutoFocus) return;
|
|
|
|
|
|
|
|
if (widget.isActive && hasLoadedData && !hasFocused && _loadedItems.isNotEmpty) {
|
|
|
|
|
|
|
|
hasFocused = true;
|
|
|
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
|
|
|
if (mounted) focusFirstItem();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Override loadData to use our custom _loadContent
|
|
|
|
// Override loadData to use our custom _loadContent
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
Future<List<PlexMetadata>> loadData() async {
|
|
|
|
Future<List<PlexMetadata>> loadData() async {
|
|
|
@@ -240,11 +275,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (items.isNotEmpty) {
|
|
|
|
if (_loadedItems.isNotEmpty) {
|
|
|
|
// Request immediately, then once more on the next frame to handle cases
|
|
|
|
// Request immediately, then once more on the next frame to handle cases
|
|
|
|
// where the grid/list attaches after the initial focus attempt.
|
|
|
|
// where the grid/list attaches after the initial focus attempt.
|
|
|
|
void request() {
|
|
|
|
void request() {
|
|
|
|
if (mounted && items.isNotEmpty && !firstItemFocusNode.hasFocus) {
|
|
|
|
if (mounted && _loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
|
|
|
|
firstItemFocusNode.requestFocus();
|
|
|
|
firstItemFocusNode.requestFocus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -268,7 +303,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
// Cancel any pending request
|
|
|
|
// Cancel any pending request
|
|
|
|
_cancelToken?.cancel();
|
|
|
|
_cancelToken?.cancel();
|
|
|
|
_cancelToken = CancelToken();
|
|
|
|
_cancelToken = CancelToken();
|
|
|
|
final currentRequestId = ++_requestId;
|
|
|
|
// Use a generation counter for the filter/sort loading phase
|
|
|
|
|
|
|
|
final generation = ++_requestId;
|
|
|
|
|
|
|
|
|
|
|
|
// Extract context dependencies before async gap - use server-specific client
|
|
|
|
// Extract context dependencies before async gap - use server-specific client
|
|
|
|
final client = getClientForLibrary();
|
|
|
|
final client = getClientForLibrary();
|
|
|
@@ -277,8 +313,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
isLoading = true;
|
|
|
|
isLoading = true;
|
|
|
|
errorMessage = null;
|
|
|
|
errorMessage = null;
|
|
|
|
items = [];
|
|
|
|
items = [];
|
|
|
|
_currentPage = 0;
|
|
|
|
_totalSize = 0;
|
|
|
|
_hasMoreItems = true;
|
|
|
|
_loadedItems.clear();
|
|
|
|
|
|
|
|
_loadingRanges.clear();
|
|
|
|
// Clear filter/sort state while loading to prevent showing stale options
|
|
|
|
// Clear filter/sort state while loading to prevent showing stale options
|
|
|
|
_filters = [];
|
|
|
|
_filters = [];
|
|
|
|
_sortOptions = [];
|
|
|
|
_sortOptions = [];
|
|
|
@@ -303,8 +340,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
final savedSort = storage.getLibrarySort(widget.library.globalKey);
|
|
|
|
final savedSort = storage.getLibrarySort(widget.library.globalKey);
|
|
|
|
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
|
|
|
|
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if request was cancelled
|
|
|
|
// Check if request was superseded
|
|
|
|
if (currentRequestId != _requestId) return;
|
|
|
|
if (generation != _requestId) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
@@ -327,62 +364,64 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Load items and first characters in parallel
|
|
|
|
// Load items and first characters in parallel
|
|
|
|
|
|
|
|
// _loadItems manages its own requestId internally
|
|
|
|
await Future.wait([_loadItems(), _loadFirstCharacters()]);
|
|
|
|
await Future.wait([_loadItems(), _loadFirstCharacters()]);
|
|
|
|
} catch (e) {
|
|
|
|
} catch (e) {
|
|
|
|
_handleLoadError(e, currentRequestId);
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
errorMessage = _getErrorMessage(e);
|
|
|
|
|
|
|
|
isLoading = false;
|
|
|
|
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _loadItems({bool loadMore = false}) async {
|
|
|
|
/// Build the filter params map for API calls
|
|
|
|
if (loadMore && isLoading) return;
|
|
|
|
Map<String, String> _buildFilterParams() {
|
|
|
|
|
|
|
|
final filterParams = Map<String, String>.from(_selectedFilters);
|
|
|
|
|
|
|
|
|
|
|
|
if (!loadMore) {
|
|
|
|
// Add grouping type filter (but not for 'all' or 'folders')
|
|
|
|
_currentPage = 0;
|
|
|
|
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
|
|
|
|
_hasMoreItems = true;
|
|
|
|
final typeId = _getGroupingTypeId();
|
|
|
|
|
|
|
|
if (typeId.isNotEmpty) {
|
|
|
|
|
|
|
|
filterParams['type'] = typeId;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!_hasMoreItems) return;
|
|
|
|
// Add sort
|
|
|
|
|
|
|
|
if (_selectedSort != null) {
|
|
|
|
|
|
|
|
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final currentRequestId = _requestId;
|
|
|
|
return filterParams;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _loadItems() async {
|
|
|
|
|
|
|
|
final currentRequestId = ++_requestId;
|
|
|
|
_cancelToken?.cancel();
|
|
|
|
_cancelToken?.cancel();
|
|
|
|
_cancelToken = CancelToken();
|
|
|
|
_cancelToken = CancelToken();
|
|
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
isLoading = true;
|
|
|
|
isLoading = true;
|
|
|
|
if (!loadMore) {
|
|
|
|
items = [];
|
|
|
|
items = [];
|
|
|
|
_totalSize = 0;
|
|
|
|
// Increment content version when loading fresh content
|
|
|
|
_loadedItems.clear();
|
|
|
|
// This invalidates the last focused index
|
|
|
|
_loadingRanges.clear();
|
|
|
|
gridContentVersion++;
|
|
|
|
// Increment content version when loading fresh content
|
|
|
|
cleanupGridFocusNodes(items.length);
|
|
|
|
// This invalidates the last focused index
|
|
|
|
}
|
|
|
|
gridContentVersion++;
|
|
|
|
|
|
|
|
cleanupGridFocusNodes(0);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
// Use server-specific client for this library
|
|
|
|
// Use server-specific client for this library
|
|
|
|
final client = getClientForLibrary();
|
|
|
|
final client = getClientForLibrary();
|
|
|
|
|
|
|
|
final filterParams = _buildFilterParams();
|
|
|
|
// Build filter params
|
|
|
|
|
|
|
|
final filterParams = Map<String, String>.from(_selectedFilters);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Add grouping type filter (but not for 'all' or 'folders')
|
|
|
|
|
|
|
|
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
|
|
|
|
|
|
|
|
final typeId = _getGroupingTypeId();
|
|
|
|
|
|
|
|
if (typeId.isNotEmpty) {
|
|
|
|
|
|
|
|
filterParams['type'] = typeId;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Add sort
|
|
|
|
|
|
|
|
if (_selectedSort != null) {
|
|
|
|
|
|
|
|
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Items are automatically tagged with server info by PlexClient
|
|
|
|
// Items are automatically tagged with server info by PlexClient
|
|
|
|
final loadedItems = await client.getLibraryContent(
|
|
|
|
final result = await client.getLibraryContent(
|
|
|
|
widget.library.key,
|
|
|
|
widget.library.key,
|
|
|
|
start: _currentPage * _pageSize,
|
|
|
|
start: 0,
|
|
|
|
size: _pageSize,
|
|
|
|
size: _fetchSize,
|
|
|
|
filters: filterParams,
|
|
|
|
filters: filterParams,
|
|
|
|
cancelToken: _cancelToken,
|
|
|
|
cancelToken: _cancelToken,
|
|
|
|
);
|
|
|
|
);
|
|
|
@@ -391,33 +430,81 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
|
|
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
|
|
setState(() {
|
|
|
|
if (loadMore) {
|
|
|
|
_totalSize = result.totalSize;
|
|
|
|
items.addAll(loadedItems);
|
|
|
|
for (var i = 0; i < result.items.length; i++) {
|
|
|
|
} else {
|
|
|
|
_loadedItems[i] = result.items[i];
|
|
|
|
items = loadedItems;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
_hasMoreItems = loadedItems.length >= _pageSize;
|
|
|
|
|
|
|
|
_currentPage++;
|
|
|
|
|
|
|
|
isLoading = false;
|
|
|
|
isLoading = false;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// On initial load (not pagination), mark data as loaded and try to focus
|
|
|
|
hasLoadedData = true;
|
|
|
|
if (!loadMore) {
|
|
|
|
tryFocus();
|
|
|
|
hasLoadedData = true;
|
|
|
|
|
|
|
|
tryFocus();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Notify parent
|
|
|
|
// Notify parent
|
|
|
|
if (widget.onDataLoaded != null) {
|
|
|
|
if (widget.onDataLoaded != null) {
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
widget.onDataLoaded!();
|
|
|
|
widget.onDataLoaded!();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
} catch (e) {
|
|
|
|
_handleLoadError(e, currentRequestId);
|
|
|
|
_handleLoadError(e, currentRequestId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Fetch a range of items from the API and store them in the sparse map.
|
|
|
|
|
|
|
|
/// After a successful fetch, re-checks for remaining gaps in the visible range.
|
|
|
|
|
|
|
|
Future<void> _fetchRange(int start, int size) async {
|
|
|
|
|
|
|
|
// Clamp to totalSize
|
|
|
|
|
|
|
|
if (start >= _totalSize) return;
|
|
|
|
|
|
|
|
final clampedSize = size.clamp(0, _totalSize - start);
|
|
|
|
|
|
|
|
if (clampedSize == 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Deduplicate: track every index in-flight to prevent overlapping fetches
|
|
|
|
|
|
|
|
final indices = List.generate(clampedSize, (i) => start + i);
|
|
|
|
|
|
|
|
if (indices.every((i) => _loadingRanges.contains(i) || _loadedItems.containsKey(i))) return;
|
|
|
|
|
|
|
|
_loadingRanges.addAll(indices);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final currentRequestId = _requestId;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
final client = getClientForLibrary();
|
|
|
|
|
|
|
|
final filterParams = _buildFilterParams();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final result = await client.getLibraryContent(
|
|
|
|
|
|
|
|
widget.library.key,
|
|
|
|
|
|
|
|
start: start,
|
|
|
|
|
|
|
|
size: clampedSize,
|
|
|
|
|
|
|
|
filters: filterParams,
|
|
|
|
|
|
|
|
cancelToken: _cancelToken,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (currentRequestId != _requestId || !mounted) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
|
|
for (var i = 0; i < result.items.length; i++) {
|
|
|
|
|
|
|
|
_loadedItems[start + i] = result.items[i];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update totalSize in case it changed (e.g., items added/removed on server)
|
|
|
|
|
|
|
|
if (result.totalSize != _totalSize) {
|
|
|
|
|
|
|
|
_totalSize = result.totalSize;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Re-check for remaining gaps in the visible range after this fetch
|
|
|
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
|
|
|
if (mounted && currentRequestId == _requestId) {
|
|
|
|
|
|
|
|
_loadVisibleRange();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
// Silently ignore fetch errors for background range loads
|
|
|
|
|
|
|
|
// (the initial load handles errors with UI feedback)
|
|
|
|
|
|
|
|
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
_loadingRanges.removeAll(indices);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleLoadError(dynamic error, int currentRequestId) {
|
|
|
|
void _handleLoadError(dynamic error, int currentRequestId) {
|
|
|
|
if (currentRequestId != _requestId) return;
|
|
|
|
if (currentRequestId != _requestId) return;
|
|
|
|
|
|
|
|
|
|
|
@@ -617,9 +704,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (items.isEmpty) return;
|
|
|
|
if (_totalSize == 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < items.length ? lastFocusedGridIndex! : 0;
|
|
|
|
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < _totalSize && _loadedItems.containsKey(lastFocusedGridIndex!) ? lastFocusedGridIndex! : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
|
|
|
|
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
|
|
|
|
if (targetIndex == 0) {
|
|
|
|
if (targetIndex == 0) {
|
|
|
@@ -634,10 +721,33 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
/// FocusNode detached), so we target the last-column item in the first
|
|
|
|
/// FocusNode detached), so we target the last-column item in the first
|
|
|
|
/// visible row — the grid cell closest to the alpha bar.
|
|
|
|
/// visible row — the grid cell closest to the alpha bar.
|
|
|
|
void _navigateToGridNearScroll() {
|
|
|
|
void _navigateToGridNearScroll() {
|
|
|
|
if (items.isEmpty || _currentColumnCount < 1) return;
|
|
|
|
if (_totalSize == 0 || _currentColumnCount < 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
final row = _currentFirstVisibleIndex ~/ _currentColumnCount;
|
|
|
|
final row = _currentFirstVisibleIndex ~/ _currentColumnCount;
|
|
|
|
final targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, items.length - 1);
|
|
|
|
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, _totalSize - 1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Find nearest loaded item — skeleton cards have no FocusNode
|
|
|
|
|
|
|
|
if (!_loadedItems.containsKey(targetIndex)) {
|
|
|
|
|
|
|
|
// Search backwards first (items above are more likely visible)
|
|
|
|
|
|
|
|
int? found;
|
|
|
|
|
|
|
|
for (var i = targetIndex - 1; i >= 0; i--) {
|
|
|
|
|
|
|
|
if (_loadedItems.containsKey(i)) {
|
|
|
|
|
|
|
|
found = i;
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// Then search forwards
|
|
|
|
|
|
|
|
if (found == null) {
|
|
|
|
|
|
|
|
for (var i = targetIndex + 1; i < _totalSize; i++) {
|
|
|
|
|
|
|
|
if (_loadedItems.containsKey(i)) {
|
|
|
|
|
|
|
|
found = i;
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (found == null) return;
|
|
|
|
|
|
|
|
targetIndex = found;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetIndex == 0) {
|
|
|
|
if (targetIndex == 0) {
|
|
|
|
firstItemFocusNode.requestFocus();
|
|
|
|
firstItemFocusNode.requestFocus();
|
|
|
@@ -710,11 +820,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Track scroll position to highlight the current letter in the jump bar
|
|
|
|
/// Track scroll position and trigger debounced range loading.
|
|
|
|
void _onScrollChanged() {
|
|
|
|
void _onScrollChanged() {
|
|
|
|
|
|
|
|
// Debounced scroll-idle handler: load visible range when scrolling settles
|
|
|
|
|
|
|
|
_scrollIdleTimer?.cancel();
|
|
|
|
|
|
|
|
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
|
|
|
|
|
|
|
|
if (mounted) _loadVisibleRange();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
|
|
|
|
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
// During a jump animation, skip all processing to avoid flashing.
|
|
|
|
// During a jump animation, skip alpha bar processing to avoid flashing.
|
|
|
|
if (_isJumpScrolling) return;
|
|
|
|
if (_isJumpScrolling) return;
|
|
|
|
|
|
|
|
|
|
|
|
// If pinned from a completed jump, the next scroll event must be
|
|
|
|
// If pinned from a completed jump, the next scroll event must be
|
|
|
@@ -733,7 +849,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
final firstInRow = _itemIndexFromScrollOffset(offset);
|
|
|
|
final firstInRow = _itemIndexFromScrollOffset(offset);
|
|
|
|
// Use the last item in the first visible row so the highlighted letter
|
|
|
|
// Use the last item in the first visible row so the highlighted letter
|
|
|
|
// updates as soon as items with a new letter appear in that row.
|
|
|
|
// updates as soon as items with a new letter appear in that row.
|
|
|
|
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, items.length - 1);
|
|
|
|
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
|
|
|
|
|
|
|
|
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex);
|
|
|
|
if (lastInRow != _currentFirstVisibleIndex) {
|
|
|
|
if (lastInRow != _currentFirstVisibleIndex) {
|
|
|
|
setState(() => _currentFirstVisibleIndex = lastInRow);
|
|
|
|
setState(() => _currentFirstVisibleIndex = lastInRow);
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -754,7 +871,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
// First visible row = (offset + chipsBarHeight - effectiveTopPadding) / rowHeight
|
|
|
|
// First visible row = (offset + chipsBarHeight - effectiveTopPadding) / rowHeight
|
|
|
|
final contentOffset = (offset + _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
|
|
|
|
final contentOffset = (offset + _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
|
|
|
|
final row = (contentOffset / rowHeight).floor();
|
|
|
|
final row = (contentOffset / rowHeight).floor();
|
|
|
|
return (row * _currentColumnCount).clamp(0, items.length - 1);
|
|
|
|
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
|
|
|
|
|
|
|
|
return (row * _currentColumnCount).clamp(0, maxIndex);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Scroll to the item at [targetIndex], loading more pages if necessary.
|
|
|
|
/// Scroll to the item at [targetIndex], loading more pages if necessary.
|
|
|
@@ -766,13 +884,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
_isJumpScrolling = true;
|
|
|
|
_isJumpScrolling = true;
|
|
|
|
|
|
|
|
|
|
|
|
_hasJumpPin = true;
|
|
|
|
_hasJumpPin = true;
|
|
|
|
setState(() => _currentFirstVisibleIndex = targetIndex);
|
|
|
|
final clamped = targetIndex.clamp(0, _totalSize > 0 ? _totalSize - 1 : 0);
|
|
|
|
|
|
|
|
setState(() => _currentFirstVisibleIndex = clamped);
|
|
|
|
|
|
|
|
|
|
|
|
if (targetIndex < items.length) {
|
|
|
|
_scrollToItemIndex(clamped);
|
|
|
|
_scrollToItemIndex(targetIndex);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
_loadUntilIndex(targetIndex);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Scroll the grid so that [index] is visible just below the chips bar
|
|
|
|
/// Scroll the grid so that [index] is visible just below the chips bar
|
|
|
@@ -809,16 +924,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Load pages until [targetIndex] is loaded, then scroll to it
|
|
|
|
|
|
|
|
Future<void> _loadUntilIndex(int targetIndex) async {
|
|
|
|
|
|
|
|
while (items.length <= targetIndex && _hasMoreItems) {
|
|
|
|
|
|
|
|
await _loadItems(loadMore: true);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
|
|
_scrollToItemIndex(targetIndex.clamp(0, items.length - 1));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
|
|
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
|
|
@@ -876,13 +981,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Builds the scrollable content (grid/list) with pagination support
|
|
|
|
/// Builds the scrollable content (grid/list) with scroll-idle loading
|
|
|
|
Widget _buildScrollableContent() {
|
|
|
|
Widget _buildScrollableContent() {
|
|
|
|
return NotificationListener<ScrollNotification>(
|
|
|
|
return NotificationListener<ScrollNotification>(
|
|
|
|
onNotification: (notification) {
|
|
|
|
onNotification: (notification) {
|
|
|
|
if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && !isLoading) {
|
|
|
|
|
|
|
|
_loadItems(loadMore: true);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// Track scroll activity for phone scroll handle
|
|
|
|
// Track scroll activity for phone scroll handle
|
|
|
|
if (notification is ScrollStartNotification) {
|
|
|
|
if (notification is ScrollStartNotification) {
|
|
|
|
if (!_isScrollActive) setState(() => _isScrollActive = true);
|
|
|
|
if (!_isScrollActive) setState(() => _isScrollActive = true);
|
|
|
@@ -904,6 +1006,48 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Determine the visible range and fetch any unloaded items within it.
|
|
|
|
|
|
|
|
/// Covers the full visible area plus a buffer of _fetchSize/2 on each side,
|
|
|
|
|
|
|
|
/// then finds the first unloaded contiguous block and fetches it.
|
|
|
|
|
|
|
|
void _loadVisibleRange() {
|
|
|
|
|
|
|
|
if (_totalSize == 0 || _currentColumnCount < 1 || !_scrollController.hasClients) return;
|
|
|
|
|
|
|
|
if (_lastCrossAxisExtent <= 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final offset = _scrollController.offset;
|
|
|
|
|
|
|
|
final viewportHeight = _scrollController.position.viewportDimension;
|
|
|
|
|
|
|
|
final firstIndex = _itemIndexFromScrollOffset(offset);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate how many items fit in the viewport
|
|
|
|
|
|
|
|
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
|
|
|
|
|
|
|
|
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
|
|
|
|
|
|
|
|
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
|
|
|
|
|
|
|
|
if (rowHeight <= 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
|
|
|
|
|
|
|
|
final visibleCount = visibleRows * _currentColumnCount;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Expand the visible range by a buffer on each side
|
|
|
|
|
|
|
|
final buffer = _fetchSize ~/ 2;
|
|
|
|
|
|
|
|
final rangeStart = (firstIndex - buffer).clamp(0, _totalSize);
|
|
|
|
|
|
|
|
final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, _totalSize);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Find the first and last unloaded indices in the range
|
|
|
|
|
|
|
|
int? fetchStart;
|
|
|
|
|
|
|
|
int? fetchEnd;
|
|
|
|
|
|
|
|
for (var i = rangeStart; i < rangeEnd; i++) {
|
|
|
|
|
|
|
|
if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
|
|
|
|
|
|
|
|
fetchStart ??= i;
|
|
|
|
|
|
|
|
fetchEnd = i + 1;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (fetchStart == null || fetchEnd == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
final fetchSize = fetchEnd - fetchStart;
|
|
|
|
|
|
|
|
if (fetchSize <= 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_fetchRange(fetchStart, fetchSize);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether the filters chip is visible
|
|
|
|
/// Whether the filters chip is visible
|
|
|
|
bool get _isFiltersChipVisible => _filters.isNotEmpty && _selectedGrouping != 'folders';
|
|
|
|
bool get _isFiltersChipVisible => _filters.isNotEmpty && _selectedGrouping != 'folders';
|
|
|
|
|
|
|
|
|
|
|
@@ -976,11 +1120,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
|
|
|
|
|
|
|
|
/// Builds content as slivers for the CustomScrollView
|
|
|
|
/// Builds content as slivers for the CustomScrollView
|
|
|
|
List<Widget> _buildContentSlivers() {
|
|
|
|
List<Widget> _buildContentSlivers() {
|
|
|
|
if (isLoading && items.isEmpty) {
|
|
|
|
if (isLoading && _totalSize == 0 && _loadedItems.isEmpty) {
|
|
|
|
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
|
|
|
|
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (errorMessage != null && items.isEmpty) {
|
|
|
|
if (errorMessage != null && _loadedItems.isEmpty) {
|
|
|
|
return [
|
|
|
|
return [
|
|
|
|
SliverFillRemaining(
|
|
|
|
SliverFillRemaining(
|
|
|
|
child: ErrorStateWidget(
|
|
|
|
child: ErrorStateWidget(
|
|
|
@@ -993,7 +1137,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
];
|
|
|
|
];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (items.isEmpty) {
|
|
|
|
if (_totalSize == 0 && !isLoading) {
|
|
|
|
return [
|
|
|
|
return [
|
|
|
|
SliverFillRemaining(
|
|
|
|
SliverFillRemaining(
|
|
|
|
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
|
|
|
|
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
|
|
|
@@ -1021,7 +1165,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
|
|
|
|
|
|
|
|
/// Builds either a sliver list or sliver grid based on the view mode
|
|
|
|
/// Builds either a sliver list or sliver grid based on the view mode
|
|
|
|
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
|
|
|
|
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
|
|
|
|
final itemCount = items.length + (_hasMoreItems && isLoading ? 1 : 0);
|
|
|
|
final itemCount = _totalSize;
|
|
|
|
final isPhone = _isPhone(context);
|
|
|
|
final isPhone = _isPhone(context);
|
|
|
|
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
|
|
|
|
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
|
|
|
|
_effectiveTopPadding = topPadding;
|
|
|
|
_effectiveTopPadding = topPadding;
|
|
|
@@ -1080,13 +1224,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
required bool isFirstColumn,
|
|
|
|
required bool isFirstColumn,
|
|
|
|
bool isLastColumn = false,
|
|
|
|
bool isLastColumn = false,
|
|
|
|
}) {
|
|
|
|
}) {
|
|
|
|
if (index >= items.length) {
|
|
|
|
final item = _loadedItems[index];
|
|
|
|
return const Padding(
|
|
|
|
|
|
|
|
padding: EdgeInsets.all(16.0),
|
|
|
|
// Show skeleton placeholder for unloaded items
|
|
|
|
child: Center(child: CircularProgressIndicator()),
|
|
|
|
if (item == null) {
|
|
|
|
);
|
|
|
|
return const _SkeletonCard();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
final item = items[index];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
|
|
|
|
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
|
|
|
|
// All other items get managed focus nodes for restoration
|
|
|
|
// All other items get managed focus nodes for restoration
|
|
|
@@ -1106,3 +1249,34 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Skeleton placeholder card that matches the poster + title layout of a real media card.
|
|
|
|
|
|
|
|
/// Not focusable — dpad focus skips over these.
|
|
|
|
|
|
|
|
class _SkeletonCard extends StatelessWidget {
|
|
|
|
|
|
|
|
const _SkeletonCard();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
|
|
children: [
|
|
|
|
|
|
|
|
// Poster area
|
|
|
|
|
|
|
|
const Expanded(child: SkeletonLoader()),
|
|
|
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
|
|
// Title bar
|
|
|
|
|
|
|
|
const SkeletonLoader(
|
|
|
|
|
|
|
|
child: SizedBox(height: 10, width: double.infinity),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
|
|
|
|
// Subtitle bar
|
|
|
|
|
|
|
|
const FractionallySizedBox(
|
|
|
|
|
|
|
|
alignment: Alignment.centerLeft,
|
|
|
|
|
|
|
|
widthFactor: 0.6,
|
|
|
|
|
|
|
|
child: SkeletonLoader(
|
|
|
|
|
|
|
|
child: SizedBox(height: 8, width: double.infinity),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|