diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 5c7cb83e..1f0c7784 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -524,7 +524,7 @@ class _LibrariesScreenState extends State ) async { while (_hasMoreItems && requestId == _requestId) { try { - final items = await client.getLibraryContent( + final result = await client.getLibraryContent( library.key, start: _currentPage * _pageSize, size: _pageSize, @@ -533,7 +533,7 @@ class _LibrariesScreenState extends State ); // 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)) .toList(); diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index 75bf54fd..ca588ea7 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -76,7 +76,8 @@ abstract class BaseLibraryTabState> extends State // Focus management bool _hasLoadedData = false; - bool _hasFocused = false; + @protected + bool hasFocused = false; // Getters for subclasses List get items => _items; @@ -122,7 +123,7 @@ abstract class BaseLibraryTabState> extends State // Reload if library changed if (oldWidget.library.globalKey != widget.library.globalKey) { // Reset focus state for new library - _hasFocused = false; + hasFocused = false; _hasLoadedData = false; // Immediately clear stale data before async load _items = []; @@ -164,8 +165,8 @@ abstract class BaseLibraryTabState> extends State // Don't auto-focus if suppressed (e.g., when navigating via tab bar) if (widget.suppressAutoFocus) return; - if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { - _hasFocused = true; + if (widget.isActive && _hasLoadedData && !hasFocused && _items.isNotEmpty) { + hasFocused = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { focusFirstItem(); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 4f6a3388..b8321fcd 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -18,6 +18,7 @@ import '../../../widgets/alpha_jump_bar.dart'; import '../../../widgets/alpha_jump_helper.dart'; import '../../../widgets/alpha_scroll_handle.dart'; import '../../../widgets/focusable_media_card.dart'; +import '../../../widgets/media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/overlay_sheet.dart'; @@ -68,14 +69,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.serverId; @override - Set? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet(); + Set? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet(); @override Set? get deletionGlobalKeys { - if (items.isEmpty) return {}; + if (_loadedItems.isEmpty) return {}; final keys = {}; - for (final item in items) { + for (final item in _loadedItems.values) { final serverId = item.serverId ?? widget.library.serverId; if (serverId == null) return null; keys.add(_toGlobalKey(item.ratingKey, serverId: serverId)); @@ -85,30 +86,30 @@ class _LibraryBrowseTabState extends BaseLibraryTabState e.ratingKey == event.ratingKey); - if (index != -1) { + // If we have an item that matches the rating key exactly, remove it and rebuild indices + final matchEntry = _loadedItems.entries.where((e) => e.value.ratingKey == event.ratingKey).firstOrNull; + if (matchEntry != null) { setState(() { - items.removeAt(index); + _removeLoadedItemAndShift(matchEntry.key); }); 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. // Otherwise, just update the counts. for (final parentKey in event.parentChain) { - final parentIndex = items.indexWhere((e) => e.ratingKey == parentKey); - if (parentIndex != -1) { - final item = items[parentIndex]; + final parentEntry = _loadedItems.entries.where((e) => e.value.ratingKey == parentKey).firstOrNull; + if (parentEntry != null) { + final item = parentEntry.value; final newLeafCount = (item.leafCount ?? 1) - event.leafCount; if (newLeafCount <= 0) { setState(() { - items.removeAt(parentIndex); + _removeLoadedItemAndShift(parentEntry.key); }); } else { setState(() { - items[parentIndex] = item.copyWith(leafCount: newLeafCount); + _loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount); }); } return; @@ -116,18 +117,37 @@ class _LibraryBrowseTabState extends BaseLibraryTabState{}; + 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 String get focusNodeDebugLabel => 'browse_first_item'; @override - int get itemCount => items.length; + int get itemCount => _totalSize; @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { setState(() { - final index = items.indexWhere((item) => item.ratingKey == ratingKey); - if (index != -1) { - items[index] = updatedMetadata; + for (final entry in _loadedItems.entries) { + if (entry.value.ratingKey == ratingKey) { + _loadedItems[entry.key] = updatedMetadata; + break; + } } }); } @@ -162,11 +182,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadedItems = {}; + final Set _loadingRanges = {}; CancelToken? _cancelToken; int _requestId = 0; - static const int _pageSize = 500; + static const int _fetchSize = 200; + Timer? _scrollIdleTimer; // Focus nodes for filter chips final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip'); @@ -186,6 +208,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> loadData() async { @@ -240,11 +275,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadItems({bool loadMore = false}) async { - if (loadMore && isLoading) return; + /// Build the filter params map for API calls + Map _buildFilterParams() { + final filterParams = Map.from(_selectedFilters); - if (!loadMore) { - _currentPage = 0; - _hasMoreItems = true; + // Add grouping type filter (but not for 'all' or 'folders') + if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') { + 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 _loadItems() async { + final currentRequestId = ++_requestId; _cancelToken?.cancel(); _cancelToken = CancelToken(); setState(() { isLoading = true; - if (!loadMore) { - items = []; - // Increment content version when loading fresh content - // This invalidates the last focused index - gridContentVersion++; - cleanupGridFocusNodes(items.length); - } + items = []; + _totalSize = 0; + _loadedItems.clear(); + _loadingRanges.clear(); + // Increment content version when loading fresh content + // This invalidates the last focused index + gridContentVersion++; + cleanupGridFocusNodes(0); }); try { // Use server-specific client for this library final client = getClientForLibrary(); - - // Build filter params - final filterParams = Map.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); - } + final filterParams = _buildFilterParams(); // Items are automatically tagged with server info by PlexClient - final loadedItems = await client.getLibraryContent( + final result = await client.getLibraryContent( widget.library.key, - start: _currentPage * _pageSize, - size: _pageSize, + start: 0, + size: _fetchSize, filters: filterParams, cancelToken: _cancelToken, ); @@ -391,33 +430,81 @@ class _LibraryBrowseTabState extends BaseLibraryTabState= _pageSize; - _currentPage++; isLoading = false; }); - // On initial load (not pagination), mark data as loaded and try to focus - if (!loadMore) { - hasLoadedData = true; - tryFocus(); + hasLoadedData = true; + tryFocus(); - // Notify parent - if (widget.onDataLoaded != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onDataLoaded!(); - }); - } + // Notify parent + if (widget.onDataLoaded != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onDataLoaded!(); + }); } } catch (e) { _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 _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) { if (currentRequestId != _requestId) return; @@ -617,9 +704,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState= 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) { firstItemFocusNode.requestFocus(); @@ -710,11 +820,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0; + final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex); if (lastInRow != _currentFirstVisibleIndex) { setState(() => _currentFirstVisibleIndex = lastInRow); } @@ -754,7 +871,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0; + return (row * _currentColumnCount).clamp(0, maxIndex); } /// Scroll to the item at [targetIndex], loading more pages if necessary. @@ -766,13 +884,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _currentFirstVisibleIndex = targetIndex); + final clamped = targetIndex.clamp(0, _totalSize > 0 ? _totalSize - 1 : 0); + setState(() => _currentFirstVisibleIndex = clamped); - if (targetIndex < items.length) { - _scrollToItemIndex(targetIndex); - } else { - _loadUntilIndex(targetIndex); - } + _scrollToItemIndex(clamped); } /// Scroll the grid so that [index] is visible just below the chips bar @@ -809,16 +924,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadUntilIndex(int targetIndex) async { - while (items.length <= targetIndex && _hasMoreItems) { - await _loadItems(loadMore: true); - } - if (mounted) { - _scrollToItemIndex(targetIndex.clamp(0, items.length - 1)); - } - } - @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin @@ -876,13 +981,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( onNotification: (notification) { - if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && !isLoading) { - _loadItems(loadMore: true); - } // Track scroll activity for phone scroll handle if (notification is ScrollStartNotification) { if (!_isScrollActive) setState(() => _isScrollActive = true); @@ -904,6 +1006,48 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _filters.isNotEmpty && _selectedGrouping != 'folders'; @@ -976,11 +1120,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildContentSlivers() { - if (isLoading && items.isEmpty) { + if (isLoading && _totalSize == 0 && _loadedItems.isEmpty) { return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))]; } - if (errorMessage != null && items.isEmpty) { + if (errorMessage != null && _loadedItems.isEmpty) { return [ SliverFillRemaining( child: ErrorStateWidget( @@ -993,7 +1137,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState= items.length) { - return const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: CircularProgressIndicator()), - ); + final item = _loadedItems[index]; + + // Show skeleton placeholder for unloaded items + if (item == null) { + return const _SkeletonCard(); } - final item = items[index]; // Use firstItemFocusNode for index 0 to maintain compatibility with base class // All other items get managed focus nodes for restoration @@ -1106,3 +1249,34 @@ class _LibraryBrowseTabState extends BaseLibraryTabState items; + final int totalSize; + const LibraryContentResult({required this.items, required this.totalSize}); +} + /// Process hub JSON response in an isolate. /// Top-level function so it can be passed to [Isolate.run]. List _processHubResponse(String jsonStr, String serverId, String? serverName) { @@ -372,7 +379,7 @@ class PlexClient { } /// Get library content by section ID - Future> getLibraryContent( + Future getLibraryContent( String sectionId, { int? start, int? size, @@ -394,7 +401,11 @@ class PlexClient { 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 diff --git a/pubspec.lock b/pubspec.lock index b1ce06ad..99582c4e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -215,10 +215,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -699,18 +699,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -1281,10 +1281,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" timing: dependency: transitive description: