feat: sparse-map pagination for library browse

This commit is contained in:
edde746
2026-02-25 18:14:48 +01:00
parent 6188d21c3d
commit 728c4854a5
5 changed files with 317 additions and 131 deletions
+2 -2
View File
@@ -524,7 +524,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
) async { ) async {
while (_hasMoreItems && requestId == _requestId) { while (_hasMoreItems && requestId == _requestId) {
try { try {
final items = await client.getLibraryContent( final result = await client.getLibraryContent(
library.key, library.key,
start: _currentPage * _pageSize, start: _currentPage * _pageSize,
size: _pageSize, size: _pageSize,
@@ -533,7 +533,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
); );
// Tag items with server info for multi-server support // Tag items with server info for multi-server support
final taggedItems = items final taggedItems = result.items
.map((item) => item.copyWith(serverId: library.serverId, serverName: library.serverName)) .map((item) => item.copyWith(serverId: library.serverId, serverName: library.serverName))
.toList(); .toList();
@@ -76,7 +76,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Focus management // Focus management
bool _hasLoadedData = false; bool _hasLoadedData = false;
bool _hasFocused = false; @protected
bool hasFocused = false;
// Getters for subclasses // Getters for subclasses
List<T> get items => _items; List<T> get items => _items;
@@ -122,7 +123,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Reload if library changed // Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) { if (oldWidget.library.globalKey != widget.library.globalKey) {
// Reset focus state for new library // Reset focus state for new library
_hasFocused = false; hasFocused = false;
_hasLoadedData = false; _hasLoadedData = false;
// Immediately clear stale data before async load // Immediately clear stale data before async load
_items = []; _items = [];
@@ -164,8 +165,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Don't auto-focus if suppressed (e.g., when navigating via tab bar) // Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (widget.suppressAutoFocus) return; if (widget.suppressAutoFocus) return;
if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { if (widget.isActive && _hasLoadedData && !hasFocused && _items.isNotEmpty) {
_hasFocused = true; hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
focusFirstItem(); focusFirstItem();
+289 -115
View File
@@ -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),
),
),
],
);
}
}
+13 -2
View File
@@ -32,6 +32,13 @@ import '../utils/plex_url_helper.dart';
import '../utils/watch_state_notifier.dart'; import '../utils/watch_state_notifier.dart';
import 'plex_api_cache.dart'; import 'plex_api_cache.dart';
/// Result of a paginated library content fetch
class LibraryContentResult {
final List<PlexMetadata> items;
final int totalSize;
const LibraryContentResult({required this.items, required this.totalSize});
}
/// Process hub JSON response in an isolate. /// Process hub JSON response in an isolate.
/// Top-level function so it can be passed to [Isolate.run]. /// Top-level function so it can be passed to [Isolate.run].
List<PlexHub> _processHubResponse(String jsonStr, String serverId, String? serverName) { List<PlexHub> _processHubResponse(String jsonStr, String serverId, String? serverName) {
@@ -372,7 +379,7 @@ class PlexClient {
} }
/// Get library content by section ID /// Get library content by section ID
Future<List<PlexMetadata>> getLibraryContent( Future<LibraryContentResult> getLibraryContent(
String sectionId, { String sectionId, {
int? start, int? start,
int? size, int? size,
@@ -394,7 +401,11 @@ class PlexClient {
cancelToken: cancelToken, cancelToken: cancelToken,
); );
return _extractMetadataList(response); final items = _extractMetadataList(response);
final container = _getMediaContainer(response);
final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length;
return LibraryContentResult(items: items, totalSize: totalSize);
} }
/// Parse list of PlexMetadata from a cached response /// Parse list of PlexMetadata from a cached response
+8 -8
View File
@@ -215,10 +215,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
charcode: charcode:
dependency: transitive dependency: transitive
description: description:
@@ -699,18 +699,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.18"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
material_symbols_icons: material_symbols_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1281,10 +1281,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.9"
timing: timing:
dependency: transitive dependency: transitive
description: description: