@@ -0,0 +1,251 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/plex_http_client.dart';
|
||||
import '../utils/plex_http_exception.dart';
|
||||
|
||||
/// Sparse-loading state + fetch orchestration for paginated item grids/lists.
|
||||
///
|
||||
/// State lives in [loadedItems] (index → item) and [totalSize]. Subclasses
|
||||
/// provide [fetchPage] to hit an endpoint; the mixin handles dedupe, retry
|
||||
/// with backoff, abort propagation, and request-generation invalidation so
|
||||
/// reloads don't collide with in-flight fetches.
|
||||
///
|
||||
/// Typical lifecycle:
|
||||
/// 1. Subclass's `loadItems` calls [resetPaginationState] inside `setState`,
|
||||
/// then awaits [loadInitialPage].
|
||||
/// 2. On scroll, subclass calls [ensureRangeLoaded] with the visible index
|
||||
/// range. Eager prefetch ahead of the viewport via [prefetchAhead].
|
||||
/// 3. On dispose, subclass calls [disposePagination].
|
||||
mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
/// Sparse map of loaded items, keyed by position.
|
||||
final Map<int, PlexMetadata> loadedItems = {};
|
||||
|
||||
/// Total items on the server. 0 until the first page completes.
|
||||
int totalSize = 0;
|
||||
|
||||
final Set<int> _loadingRanges = {};
|
||||
AbortController? _cancelToken;
|
||||
|
||||
/// Monotonic generation — bumped on reset/dispose so stale fetches are
|
||||
/// discarded instead of mutating state from a prior load.
|
||||
int _requestId = 0;
|
||||
|
||||
int _retryCount = 0;
|
||||
Timer? _retryTimer;
|
||||
bool _visibleRangeLoading = false;
|
||||
DateTime? _lastEagerPrefetch;
|
||||
|
||||
/// Re-invoked by the retry timer. Most recent range-load args.
|
||||
VoidCallback? _scheduledRetry;
|
||||
|
||||
/// Fetch a page of items. Subclass implements this — typically delegating
|
||||
/// to a paginated `PlexClient` method that returns a [LibraryContentResult].
|
||||
Future<LibraryContentResult> fetchPage(int start, int size, AbortController? abort);
|
||||
|
||||
/// Hook fired after each successful page merge. Default: no-op.
|
||||
/// Override for image prefetch, syncing a base-class `items` list, etc.
|
||||
void onPageLoaded(int start, List<PlexMetadata> items) {}
|
||||
|
||||
/// Synchronously clear pagination state and bump the generation counter.
|
||||
/// Call from inside the subclass's `setState` before awaiting
|
||||
/// [loadInitialPage]. Aborts any in-flight fetches from the previous load.
|
||||
void resetPaginationState() {
|
||||
_requestId++;
|
||||
_cancelToken?.abort();
|
||||
_cancelToken = AbortController();
|
||||
_retryTimer?.cancel();
|
||||
_retryCount = 0;
|
||||
_visibleRangeLoading = false;
|
||||
_lastEagerPrefetch = null;
|
||||
_scheduledRetry = null;
|
||||
loadedItems.clear();
|
||||
_loadingRanges.clear();
|
||||
totalSize = 0;
|
||||
}
|
||||
|
||||
/// Fetch the first page. Await from outside `setState`. Mutates
|
||||
/// [loadedItems] and [totalSize] on success; throws on failure.
|
||||
Future<LibraryContentResult> loadInitialPage(int pageSize) async {
|
||||
final generation = _requestId;
|
||||
final result = await fetchPage(0, pageSize, _cancelToken);
|
||||
if (generation != _requestId || !mounted) return result;
|
||||
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
loadedItems[i] = result.items[i];
|
||||
}
|
||||
totalSize = result.totalSize;
|
||||
onPageLoaded(0, result.items);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
|
||||
/// with [buffer] extra indices on each side. Serialized — only one
|
||||
/// range-fetch runs at a time — and re-checks after each success so a
|
||||
/// single call can backfill multiple gaps.
|
||||
Future<void> ensureRangeLoaded(int firstIndex, int visibleCount, {int buffer = 100}) async {
|
||||
if (_visibleRangeLoading || totalSize == 0) return;
|
||||
|
||||
final rangeStart = (firstIndex - buffer).clamp(0, totalSize);
|
||||
final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, totalSize);
|
||||
|
||||
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;
|
||||
|
||||
_retryTimer?.cancel();
|
||||
_scheduledRetry = () => ensureRangeLoaded(firstIndex, visibleCount, buffer: buffer);
|
||||
_visibleRangeLoading = true;
|
||||
try {
|
||||
final success = await _fetchRange(fetchStart, fetchEnd - fetchStart);
|
||||
if (success && mounted) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ensureRangeLoaded(firstIndex, visibleCount, buffer: buffer);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
_visibleRangeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Throttled eager prefetch: if anything immediately outside the viewport
|
||||
/// is unloaded, fetch a page. Runs at most once per 100ms.
|
||||
void prefetchAhead(int firstIndex, int visibleCount, {int pageSize = 200}) {
|
||||
if (totalSize == 0) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastEagerPrefetch != null && now.difference(_lastEagerPrefetch!) < const Duration(milliseconds: 100)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final lookAheadStart = (firstIndex + visibleCount).clamp(0, totalSize);
|
||||
final lookAheadEnd = (lookAheadStart + visibleCount).clamp(0, totalSize);
|
||||
for (var i = lookAheadStart; i < lookAheadEnd; i++) {
|
||||
if (!loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
|
||||
_lastEagerPrefetch = now;
|
||||
_fetchRange(i, pageSize);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final lookBehindStart = (firstIndex - visibleCount).clamp(0, totalSize);
|
||||
for (var i = firstIndex - 1; i >= lookBehindStart; i--) {
|
||||
if (!loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
|
||||
_lastEagerPrefetch = now;
|
||||
_fetchRange(i, pageSize);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict entries far from [centerIndex] once [loadedItems] exceeds
|
||||
/// [threshold], keeping [maxKeep] entries centered on [centerIndex].
|
||||
void evictDistantItems(int centerIndex, {int maxKeep = 500, int threshold = 600}) {
|
||||
if (loadedItems.length <= threshold) return;
|
||||
final halfKeep = maxKeep ~/ 2;
|
||||
loadedItems.removeWhere((index, _) => index < centerIndex - halfKeep || index > centerIndex + halfKeep);
|
||||
}
|
||||
|
||||
/// Remove the item at [index] and shift higher indices down by one.
|
||||
/// Mirrors the "one item deleted on the server" invariant: decrements
|
||||
/// [totalSize] even if [index] wasn't in the sparse map (evicted).
|
||||
void removeLoadedItemAndShift(int index) {
|
||||
loadedItems.remove(index);
|
||||
final shifted = <int, PlexMetadata>{};
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.key > index) {
|
||||
shifted[entry.key - 1] = entry.value;
|
||||
} else {
|
||||
shifted[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
loadedItems
|
||||
..clear()
|
||||
..addAll(shifted);
|
||||
totalSize = (totalSize - 1).clamp(0, totalSize);
|
||||
}
|
||||
|
||||
/// Discard the "fetch in flight" markers. In-flight network requests keep
|
||||
/// running but are no longer considered for dedupe — the next
|
||||
/// [ensureRangeLoaded] / [prefetchAhead] will re-scan the visible range.
|
||||
/// Used by scroll-idle handlers after a fast scroll where earlier eager
|
||||
/// prefetches are aimed at a now-irrelevant region.
|
||||
void clearPendingRanges() {
|
||||
_loadingRanges.clear();
|
||||
}
|
||||
|
||||
/// Ensure the page containing [index] is fetched (or already fetched).
|
||||
/// For callers that don't track viewport geometry — trigger this when a
|
||||
/// skeleton for [index] is built, and the containing page will backfill.
|
||||
/// Dedupes so multiple skeletons in the same page share one fetch.
|
||||
void ensureIndexLoaded(int index, {int pageSize = 200}) {
|
||||
if (totalSize == 0 || index >= totalSize || index < 0) return;
|
||||
if (loadedItems.containsKey(index) || _loadingRanges.contains(index)) return;
|
||||
final pageStart = (index ~/ pageSize) * pageSize;
|
||||
// Wire up the backoff retry: if this fetch fails, the retry timer in
|
||||
// _fetchRange needs something to call. Without this, a failed fetch on a
|
||||
// skeleton-only screen leaves the skeleton stuck until something else
|
||||
// triggers a rebuild.
|
||||
_scheduledRetry = () => ensureIndexLoaded(index, pageSize: pageSize);
|
||||
_fetchRange(pageStart, pageSize);
|
||||
}
|
||||
|
||||
/// Aborts in-flight requests and cancels timers. Call from `dispose()`.
|
||||
void disposePagination() {
|
||||
_requestId++;
|
||||
_cancelToken?.abort();
|
||||
_cancelToken = null;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_loadingRanges.clear();
|
||||
_scheduledRetry = null;
|
||||
}
|
||||
|
||||
Future<bool> _fetchRange(int start, int size) async {
|
||||
if (start >= totalSize) return false;
|
||||
final clampedSize = size.clamp(0, totalSize - start);
|
||||
if (clampedSize == 0) return false;
|
||||
|
||||
final indices = List.generate(clampedSize, (i) => start + i);
|
||||
if (indices.every((i) => _loadingRanges.contains(i) || loadedItems.containsKey(i))) return true;
|
||||
_loadingRanges.addAll(indices);
|
||||
|
||||
final generation = _requestId;
|
||||
|
||||
try {
|
||||
final result = await fetchPage(start, clampedSize, _cancelToken);
|
||||
if (generation != _requestId || !mounted) return false;
|
||||
|
||||
setState(() {
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
loadedItems[start + i] = result.items[i];
|
||||
}
|
||||
if (result.totalSize != totalSize) totalSize = result.totalSize;
|
||||
});
|
||||
|
||||
_retryCount = 0;
|
||||
onPageLoaded(start, result.items);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false;
|
||||
_retryCount++;
|
||||
final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4)));
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(delay, () {
|
||||
if (mounted && generation == _requestId) _scheduledRetry?.call();
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
_loadingRanges.removeAll(indices);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.getPersonMedia(widget.personId);
|
||||
return await client.fetchAllPersonMedia(widget.personId);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,14 +2,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/download_utils.dart';
|
||||
import '../utils/plex_http_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
import 'focusable_detail_screen_mixin.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
@@ -26,9 +29,11 @@ class CollectionDetailScreen extends StatefulWidget {
|
||||
|
||||
class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionDetailScreen>
|
||||
with
|
||||
StandardItemLoader<CollectionDetailScreen>,
|
||||
GridFocusNodeMixin<CollectionDetailScreen>,
|
||||
FocusableDetailScreenMixin<CollectionDetailScreen> {
|
||||
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
||||
PaginatedItemLoader<CollectionDetailScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
PlexMetadata get mediaItem => widget.collection;
|
||||
|
||||
@@ -39,33 +44,60 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
String get emptyMessage => t.collections.empty;
|
||||
|
||||
@override
|
||||
bool get hasItems => items.isNotEmpty;
|
||||
bool get hasItems => totalSize > 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposePagination();
|
||||
disposeFocusResources();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.getCollectionItems(widget.collection.ratingKey);
|
||||
Future<LibraryContentResult> fetchPage(int start, int size, AbortController? abort) =>
|
||||
client.getCollectionItems(widget.collection.ratingKey, start: start, size: size, abort: abort);
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
// Search [loadedItems] (not the flat [items] snapshot, which only has
|
||||
// the first page) so refreshing an item at a scrolled-in position updates
|
||||
// the grid in place.
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.ratingKey == ratingKey) {
|
||||
loadedItems[entry.key] = updatedMetadata;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
await super.loadItems();
|
||||
autoFocusFirstItemAfterLoad();
|
||||
}
|
||||
|
||||
@override
|
||||
String getLoadErrorMessage(Object error) {
|
||||
return t.collections.failedToLoadItems(error: error.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
String getLoadSuccessMessage(int itemCount) {
|
||||
return 'Loaded $itemCount items for collection: ${widget.collection.title}';
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
resetPaginationState();
|
||||
});
|
||||
try {
|
||||
await loadInitialPage(_pageSize);
|
||||
if (!mounted) return;
|
||||
// Mirror loadedItems into base-class [items] once so state-sliver checks
|
||||
// (items.isEmpty vs items.isEmpty && isLoading) pick the right branch.
|
||||
// Further pages only update loadedItems; items.isEmpty stays false.
|
||||
setState(() {
|
||||
items = loadedItems.values.toList();
|
||||
isLoading = false;
|
||||
});
|
||||
appLogger.d('Loaded ${loadedItems.length} of $totalSize items for collection: ${widget.collection.title}');
|
||||
autoFocusFirstItemAfterLoad();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load collection items', error: e);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
errorMessage = t.collections.failedToLoadItems(error: e.toString());
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -75,7 +107,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
final hasRule = context.select<DownloadProvider, bool>((p) => p.hasSyncRule(widget.collection.globalKey));
|
||||
|
||||
return [
|
||||
if (items.isNotEmpty) ...[
|
||||
if (hasItems) ...[
|
||||
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
|
||||
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
||||
],
|
||||
@@ -101,17 +133,19 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
}
|
||||
|
||||
Future<void> _downloadCollection() async {
|
||||
if (items.isEmpty) {
|
||||
if (!hasItems) {
|
||||
showErrorSnackBar(context, t.collections.empty);
|
||||
return;
|
||||
}
|
||||
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
try {
|
||||
final allItems = await client.fetchAllCollectionItems(widget.collection.ratingKey);
|
||||
if (!mounted) return;
|
||||
final result = await showCollectionDownloadOptionsAndQueue(
|
||||
context,
|
||||
collectionMetadata: widget.collection,
|
||||
items: items,
|
||||
items: allItems,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
);
|
||||
@@ -140,8 +174,8 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
|
||||
Future<void> _deleteCollection() async {
|
||||
int? sectionId = widget.collection.librarySectionID;
|
||||
if (sectionId == null && items.isNotEmpty) {
|
||||
sectionId = items.first.librarySectionID;
|
||||
if (sectionId == null && loadedItems.isNotEmpty) {
|
||||
sectionId = loadedItems.values.first.librarySectionID;
|
||||
}
|
||||
|
||||
if (sectionId == null) {
|
||||
@@ -185,10 +219,12 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.collection.title!), actions: buildFocusableAppBarActions()),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
buildFocusableGrid(
|
||||
items: items,
|
||||
if (hasItems)
|
||||
buildSparseFocusableGrid(
|
||||
totalItems: totalSize,
|
||||
itemAt: (index) => loadedItems[index],
|
||||
onRefresh: updateItem,
|
||||
onSkeletonVisible: (index) => ensureIndexLoaded(index, pageSize: _pageSize),
|
||||
collectionId: widget.collection.ratingKey,
|
||||
onListRefresh: loadItems,
|
||||
),
|
||||
|
||||
@@ -4,11 +4,13 @@ import '../focus/focusable_action_bar.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart' show ViewMode;
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../widgets/focusable_media_card.dart';
|
||||
import '../widgets/media_grid_delegate.dart';
|
||||
import '../widgets/skeleton_media_card.dart';
|
||||
|
||||
/// Mixin that provides common focus navigation functionality for detail screens.
|
||||
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
|
||||
@@ -224,4 +226,76 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems]
|
||||
/// slots; for each, [itemAt] returns the loaded item or null if not yet
|
||||
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
|
||||
/// the caller can kick off a page fetch containing that index.
|
||||
Widget buildSparseFocusableGrid({
|
||||
required int totalItems,
|
||||
required PlexMetadata? Function(int index) itemAt,
|
||||
required void Function(String ratingKey) onRefresh,
|
||||
void Function(int index)? onSkeletonVisible,
|
||||
String? collectionId,
|
||||
VoidCallback? onListRefresh,
|
||||
}) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final isListMode = settingsProvider.viewMode == ViewMode.list;
|
||||
|
||||
Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) {
|
||||
final item = itemAt(index);
|
||||
if (item == null) {
|
||||
onSkeletonVisible?.call(index);
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item');
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
focusNode: focusNode,
|
||||
disableScale: disableScale,
|
||||
onRefresh: onRefresh,
|
||||
collectionId: collectionId,
|
||||
onListRefresh: onListRefresh,
|
||||
onNavigateUp: inFirstRow ? navigateToAppBar : null,
|
||||
onBack: handleBackFromContent,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
);
|
||||
}
|
||||
|
||||
if (isListMode) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: totalItems,
|
||||
itemBuilder: (context, index) => buildTile(index, inFirstRow: index == 0, disableScale: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent);
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(
|
||||
context: context,
|
||||
density: settingsProvider.libraryDensity,
|
||||
),
|
||||
itemCount: totalItems,
|
||||
itemBuilder: (context, index) => buildTile(
|
||||
index,
|
||||
inFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
|
||||
disableScale: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import '../alpha_jump_bar.dart';
|
||||
import '../alpha_jump_helper.dart';
|
||||
import '../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';
|
||||
@@ -39,6 +38,8 @@ import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMod
|
||||
import '../../../mixins/grid_focus_node_mixin.dart';
|
||||
import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/deletion_aware.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../utils/deletion_notifier.dart';
|
||||
import '../../../utils/global_key_utils.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
@@ -65,7 +66,7 @@ class LibraryBrowseTab extends BaseLibraryTab<PlexMetadata> {
|
||||
}
|
||||
|
||||
class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
|
||||
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware {
|
||||
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware, PaginatedItemLoader<LibraryBrowseTab> {
|
||||
@override
|
||||
PlexClient get client => getClientForLibrary();
|
||||
|
||||
@@ -76,14 +77,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
String? get deletionServerId => widget.library.serverId;
|
||||
|
||||
@override
|
||||
Set<String>? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet();
|
||||
Set<String>? get deletionRatingKeys => loadedItems.values.map((e) => e.ratingKey).toSet();
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys {
|
||||
if (_loadedItems.isEmpty) return <String>{};
|
||||
if (loadedItems.isEmpty) return <String>{};
|
||||
|
||||
final keys = <String>{};
|
||||
for (final item in _loadedItems.values) {
|
||||
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));
|
||||
@@ -94,10 +95,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
@override
|
||||
void onDeletionEvent(DeletionEvent event) {
|
||||
// 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;
|
||||
final matchEntry = loadedItems.entries.where((e) => e.value.ratingKey == event.ratingKey).firstOrNull;
|
||||
if (matchEntry != null) {
|
||||
setState(() {
|
||||
_removeLoadedItemAndShift(matchEntry.key);
|
||||
removeLoadedItemAndShift(matchEntry.key);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -106,17 +107,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
// If all children were deleted, remove our item.
|
||||
// Otherwise, just update the counts.
|
||||
for (final parentKey in event.parentChain) {
|
||||
final parentEntry = _loadedItems.entries.where((e) => e.value.ratingKey == parentKey).firstOrNull;
|
||||
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(() {
|
||||
_removeLoadedItemAndShift(parentEntry.key);
|
||||
removeLoadedItemAndShift(parentEntry.key);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount);
|
||||
loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount);
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -126,39 +127,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
// If neither the item nor its parents are loaded (evicted), the event
|
||||
// was already filtered by DeletionAware's upstream check against
|
||||
// deletionGlobalKeys/deletionRatingKeys, so this point is unreachable.
|
||||
// The grid self-corrects when _fetchRange updates _totalSize from the
|
||||
// server response on the next scroll.
|
||||
}
|
||||
|
||||
/// 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);
|
||||
// The grid self-corrects when the next page fetch updates totalSize from
|
||||
// the server response on the next scroll.
|
||||
}
|
||||
|
||||
@override
|
||||
String get focusNodeDebugLabel => 'browse_first_item';
|
||||
|
||||
@override
|
||||
int get itemCount => _totalSize;
|
||||
int get itemCount => totalSize;
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
setState(() {
|
||||
for (final entry in _loadedItems.entries) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.ratingKey == ratingKey) {
|
||||
_loadedItems[entry.key] = updatedMetadata;
|
||||
loadedItems[entry.key] = updatedMetadata;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -194,25 +178,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
final ValueNotifier<bool> _isScrollActive = ValueNotifier<bool>(false);
|
||||
Timer? _scrollActivityTimer;
|
||||
|
||||
// Eager prefetch throttle
|
||||
DateTime? _lastEagerPrefetch;
|
||||
// Alpha bar update: throttle (leading edge) + trailing timer (ensures final position)
|
||||
DateTime? _lastAlphaUpdate;
|
||||
Timer? _alphaUpdateTimer;
|
||||
|
||||
// Pagination state
|
||||
int _totalSize = 0;
|
||||
final Map<int, PlexMetadata> _loadedItems = {};
|
||||
final Set<int> _loadingRanges = {};
|
||||
AbortController? _cancelToken;
|
||||
int _requestId = 0;
|
||||
/// 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;
|
||||
Timer? _scrollIdleTimer;
|
||||
Timer? _retryTimer;
|
||||
int _retryCount = 0;
|
||||
bool _rangeLoadScheduled = false;
|
||||
bool _visibleRangeLoading = false;
|
||||
|
||||
// Focus nodes for filter chips
|
||||
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
|
||||
@@ -230,8 +207,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelToken?.abort();
|
||||
_retryTimer?.cancel();
|
||||
disposePagination();
|
||||
_scrollActivityTimer?.cancel();
|
||||
_scrollIdleTimer?.cancel();
|
||||
_alphaUpdateTimer?.cancel();
|
||||
@@ -247,14 +223,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Override tryFocus to use _loadedItems instead of base class items list
|
||||
// Override tryFocus to use loadedItems instead of base class items list
|
||||
@override
|
||||
void tryFocus() {
|
||||
if (widget.suppressAutoFocus) return;
|
||||
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
|
||||
// from interfering with TabBarView page animations
|
||||
if (!InputModeTracker.isKeyboardMode(context)) return;
|
||||
if (widget.isActive && hasLoadedData && !hasFocused && _loadedItems.isNotEmpty) {
|
||||
if (widget.isActive && hasLoadedData && !hasFocused && loadedItems.isNotEmpty) {
|
||||
hasFocused = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) focusFirstItem();
|
||||
@@ -306,11 +282,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loadedItems.isNotEmpty) {
|
||||
if (loadedItems.isNotEmpty) {
|
||||
// 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 && !firstItemFocusNode.hasFocus) {
|
||||
if (mounted && loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
|
||||
firstItemFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
@@ -353,12 +329,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
// Cancel any pending request
|
||||
_cancelToken?.abort();
|
||||
_retryTimer?.cancel();
|
||||
_cancelToken = AbortController();
|
||||
// Use a generation counter for the filter/sort loading phase
|
||||
final generation = ++_requestId;
|
||||
final generation = ++_contentRequestId;
|
||||
final firstCharactersGeneration = ++_firstCharactersRequestId;
|
||||
|
||||
_resetForFullReload();
|
||||
@@ -370,9 +341,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
_totalSize = 0;
|
||||
_loadedItems.clear();
|
||||
_loadingRanges.clear();
|
||||
resetPaginationState();
|
||||
// Clear filter/sort state while loading to prevent showing stale options
|
||||
_filters = [];
|
||||
_sortOptions = [];
|
||||
@@ -398,7 +367,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
|
||||
|
||||
// Check if request was superseded
|
||||
if (generation != _requestId) return;
|
||||
if (generation != _contentRequestId) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -458,17 +427,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
}
|
||||
|
||||
Future<void> _loadItems() async {
|
||||
final currentRequestId = ++_requestId;
|
||||
_cancelToken?.abort();
|
||||
_retryTimer?.cancel();
|
||||
_cancelToken = AbortController();
|
||||
|
||||
final generation = _contentRequestId;
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
items = [];
|
||||
_totalSize = 0;
|
||||
_loadedItems.clear();
|
||||
_loadingRanges.clear();
|
||||
resetPaginationState();
|
||||
// Increment content version when loading fresh content
|
||||
// This invalidates the last focused index
|
||||
gridContentVersion++;
|
||||
@@ -476,27 +439,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
});
|
||||
|
||||
try {
|
||||
// Use server-specific client for this library
|
||||
final client = getClientForLibrary();
|
||||
final filterParams = _buildFilterParams();
|
||||
await loadInitialPage(_calculateInitialFetchSize());
|
||||
|
||||
// Items are automatically tagged with server info by PlexClient
|
||||
final result = await client.getLibraryContent(
|
||||
widget.library.key,
|
||||
start: 0,
|
||||
size: _calculateInitialFetchSize(),
|
||||
filters: filterParams,
|
||||
abort: _cancelToken,
|
||||
);
|
||||
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
if (!mounted) return;
|
||||
if (generation != _contentRequestId || !mounted) return;
|
||||
setState(() {
|
||||
_totalSize = result.totalSize;
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
_loadedItems[i] = result.items[i];
|
||||
}
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
@@ -510,74 +456,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
});
|
||||
}
|
||||
} 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<bool> _fetchRange(int start, int size) async {
|
||||
// Clamp to totalSize
|
||||
if (start >= _totalSize) return false;
|
||||
final clampedSize = size.clamp(0, _totalSize - start);
|
||||
if (clampedSize == 0) return false;
|
||||
|
||||
// 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 true;
|
||||
_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,
|
||||
abort: _cancelToken,
|
||||
);
|
||||
|
||||
if (currentRequestId != _requestId || !mounted) return false;
|
||||
|
||||
if (generation != _contentRequestId || !mounted) return;
|
||||
setState(() {
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
_loadedItems[start + i] = result.items[i];
|
||||
}
|
||||
if (result.totalSize != _totalSize) {
|
||||
_totalSize = result.totalSize;
|
||||
}
|
||||
errorMessage = _getErrorMessage(e);
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
_retryCount = 0;
|
||||
_prefetchImages(start, result.items);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false;
|
||||
_retryCount++;
|
||||
final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4)));
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(delay, () {
|
||||
if (mounted && currentRequestId == _requestId) {
|
||||
_loadVisibleRange();
|
||||
}
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
_loadingRanges.removeAll(indices);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLoadError(dynamic error, int currentRequestId) {
|
||||
if (currentRequestId != _requestId) return;
|
||||
@override
|
||||
Future<LibraryContentResult> fetchPage(int start, int size, AbortController? abort) {
|
||||
return getClientForLibrary().getLibraryContent(
|
||||
widget.library.key,
|
||||
start: start,
|
||||
size: size,
|
||||
filters: _buildFilterParams(),
|
||||
abort: abort,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
errorMessage = _getErrorMessage(error);
|
||||
isLoading = false;
|
||||
});
|
||||
@override
|
||||
void onPageLoaded(int start, List<PlexMetadata> pageItems) {
|
||||
_prefetchImages(start, pageItems);
|
||||
}
|
||||
|
||||
String _getDefaultGrouping() {
|
||||
@@ -776,10 +676,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
return;
|
||||
}
|
||||
|
||||
if (_totalSize == 0) return;
|
||||
if (totalSize == 0) return;
|
||||
|
||||
final targetIndex =
|
||||
shouldRestoreGridFocus && lastFocusedGridIndex! < _totalSize && _loadedItems.containsKey(lastFocusedGridIndex!)
|
||||
shouldRestoreGridFocus && lastFocusedGridIndex! < totalSize && loadedItems.containsKey(lastFocusedGridIndex!)
|
||||
? lastFocusedGridIndex!
|
||||
: 0;
|
||||
|
||||
@@ -796,25 +696,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
/// FocusNode detached), so we target the last-column item in the first
|
||||
/// visible row — the grid cell closest to the alpha bar.
|
||||
void _navigateToGridNearScroll() {
|
||||
if (_totalSize == 0 || _currentColumnCount < 1) return;
|
||||
if (totalSize == 0 || _currentColumnCount < 1) return;
|
||||
|
||||
final row = _currentFirstVisibleIndex.value ~/ _currentColumnCount;
|
||||
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, _totalSize - 1);
|
||||
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, totalSize - 1);
|
||||
|
||||
// Find nearest loaded item — skeleton cards have no FocusNode
|
||||
if (!_loadedItems.containsKey(targetIndex)) {
|
||||
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)) {
|
||||
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)) {
|
||||
for (var i = targetIndex + 1; i < totalSize; i++) {
|
||||
if (loadedItems.containsKey(i)) {
|
||||
found = i;
|
||||
break;
|
||||
}
|
||||
@@ -905,17 +805,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
_scrollIdleTimer?.cancel();
|
||||
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
if (!mounted) return;
|
||||
// Discard stale in-flight tracking from eager prefetch during scroll
|
||||
// so _loadVisibleRange sees the full gap at the settled position.
|
||||
_loadingRanges.clear();
|
||||
_loadVisibleRange();
|
||||
_evictDistantItems();
|
||||
final firstVisible = _itemIndexFromScrollOffset(_scrollController.offset);
|
||||
evictDistantFocusNodes(firstVisible);
|
||||
// Discard stale in-flight tracking from eager prefetch during scroll so
|
||||
// ensureRangeLoaded sees the full gap at the settled position.
|
||||
clearPendingRanges();
|
||||
final range = _computeVisibleRange();
|
||||
if (range != null) {
|
||||
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _fetchSize ~/ 2);
|
||||
evictDistantItems(range.firstIndex, maxKeep: 500, threshold: 600);
|
||||
evictDistantFocusNodes(range.firstIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Eager prefetch: fetch data before scroll stops
|
||||
_checkEagerPrefetch();
|
||||
final range = _computeVisibleRange();
|
||||
if (range != null) {
|
||||
prefetchAhead(range.firstIndex, range.visibleCount, pageSize: _fetchSize);
|
||||
}
|
||||
|
||||
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
|
||||
|
||||
@@ -948,7 +853,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
final firstInRow = _itemIndexFromScrollOffset(offset);
|
||||
// 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.
|
||||
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
|
||||
final maxIndex = totalSize > 0 ? totalSize - 1 : 0;
|
||||
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex);
|
||||
if (lastInRow != _currentFirstVisibleIndex.value) {
|
||||
_currentFirstVisibleIndex.value = lastInRow;
|
||||
@@ -970,7 +875,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
// First visible row = (offset + chipsBarHeight - effectiveTopPadding) / rowHeight
|
||||
final contentOffset = (offset + _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
|
||||
final row = (contentOffset / rowHeight).floor();
|
||||
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
|
||||
final maxIndex = totalSize > 0 ? totalSize - 1 : 0;
|
||||
return (row * _currentColumnCount).clamp(0, maxIndex);
|
||||
}
|
||||
|
||||
@@ -983,7 +888,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
_isJumpScrolling = true;
|
||||
|
||||
_hasJumpPin = true;
|
||||
final clamped = targetIndex.clamp(0, _totalSize > 0 ? _totalSize - 1 : 0);
|
||||
final clamped = targetIndex.clamp(0, totalSize > 0 ? totalSize - 1 : 0);
|
||||
_currentFirstVisibleIndex.value = clamped;
|
||||
|
||||
_scrollToItemIndex(clamped);
|
||||
@@ -1126,103 +1031,29 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
_rangeLoadScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_rangeLoadScheduled = false;
|
||||
if (mounted) _loadVisibleRange();
|
||||
if (!mounted) return;
|
||||
final range = _computeVisibleRange();
|
||||
if (range != null) {
|
||||
ensureRangeLoaded(range.firstIndex, range.visibleCount, buffer: _fetchSize ~/ 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Determine the visible range and fetch any unloaded items within it.
|
||||
/// Serialized: only one visible-range fetch runs at a time to prevent
|
||||
/// concurrent calls from seeing each other's _loadingRanges and producing
|
||||
/// tiny fetch sizes.
|
||||
Future<void> _loadVisibleRange() async {
|
||||
if (_visibleRangeLoading) return;
|
||||
if (_totalSize == 0 || _currentColumnCount < 1 || !_scrollController.hasClients) return;
|
||||
if (_lastCrossAxisExtent <= 0) return;
|
||||
|
||||
/// Returns the first-visible index and visible count from the scroll
|
||||
/// controller + grid metrics, or null if the viewport isn't measured yet.
|
||||
({int firstIndex, int visibleCount})? _computeVisibleRange() {
|
||||
if (_currentColumnCount < 1 || !_scrollController.hasClients || _lastCrossAxisExtent <= 0) return null;
|
||||
final offset = _scrollController.offset;
|
||||
final viewportHeight = _scrollController.position.viewportDimension;
|
||||
if (!viewportHeight.isFinite) return;
|
||||
final firstIndex = _itemIndexFromScrollOffset(offset);
|
||||
if (!viewportHeight.isFinite) return null;
|
||||
|
||||
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
|
||||
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
|
||||
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
|
||||
if (rowHeight <= 0) return;
|
||||
if (rowHeight <= 0) return null;
|
||||
|
||||
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
|
||||
final visibleCount = visibleRows * _currentColumnCount;
|
||||
|
||||
final buffer = _fetchSize ~/ 2;
|
||||
final rangeStart = (firstIndex - buffer).clamp(0, _totalSize);
|
||||
final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, _totalSize);
|
||||
|
||||
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;
|
||||
|
||||
_retryTimer?.cancel();
|
||||
_visibleRangeLoading = true;
|
||||
try {
|
||||
final success = await _fetchRange(fetchStart, fetchEnd - fetchStart);
|
||||
if (success && mounted) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _loadVisibleRange();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
_visibleRangeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Eagerly prefetch data when approaching unloaded boundaries during scroll.
|
||||
/// Throttled to at most once per 100ms to avoid spamming.
|
||||
void _checkEagerPrefetch() {
|
||||
if (_totalSize == 0 || _currentColumnCount < 1 || !_scrollController.hasClients) return;
|
||||
if (_lastCrossAxisExtent <= 0) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastEagerPrefetch != null && now.difference(_lastEagerPrefetch!) < const Duration(milliseconds: 100)) return;
|
||||
|
||||
final offset = _scrollController.offset;
|
||||
final viewportHeight = _scrollController.position.viewportDimension;
|
||||
if (!viewportHeight.isFinite) return;
|
||||
final firstIndex = _itemIndexFromScrollOffset(offset);
|
||||
|
||||
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;
|
||||
|
||||
// Look 1 viewport ahead for unloaded items
|
||||
final lookAheadStart = (firstIndex + visibleCount).clamp(0, _totalSize);
|
||||
final lookAheadEnd = (lookAheadStart + visibleCount).clamp(0, _totalSize);
|
||||
|
||||
for (var i = lookAheadStart; i < lookAheadEnd; i++) {
|
||||
if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
|
||||
_lastEagerPrefetch = now;
|
||||
_fetchRange(i, _fetchSize);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check backward if nothing needed forward
|
||||
final lookBehindStart = (firstIndex - visibleCount).clamp(0, _totalSize);
|
||||
for (var i = firstIndex - 1; i >= lookBehindStart; i--) {
|
||||
if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
|
||||
_lastEagerPrefetch = now;
|
||||
_fetchRange(i, _fetchSize);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return (firstIndex: _itemIndexFromScrollOffset(offset), visibleCount: visibleRows * _currentColumnCount);
|
||||
}
|
||||
|
||||
/// Compute initial fetch size based on viewport dimensions.
|
||||
@@ -1245,21 +1076,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict loaded items far from the current viewport to bound memory usage.
|
||||
static const int _maxLoadedItems = 500;
|
||||
static const int _evictionThreshold = 600;
|
||||
|
||||
void _evictDistantItems() {
|
||||
if (_loadedItems.length <= _evictionThreshold) return;
|
||||
|
||||
final centerIndex = _itemIndexFromScrollOffset(_scrollController.offset);
|
||||
final halfKeep = _maxLoadedItems ~/ 2;
|
||||
final keepStart = centerIndex - halfKeep;
|
||||
final keepEnd = centerIndex + halfKeep;
|
||||
|
||||
_loadedItems.removeWhere((index, _) => index < keepStart || index > keepEnd);
|
||||
}
|
||||
|
||||
/// Prefetch images for items near the viewport to reduce pop-in.
|
||||
void _prefetchImages(int startIndex, List<PlexMetadata> items) {
|
||||
if (!_scrollController.hasClients || _lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return;
|
||||
@@ -1391,11 +1207,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
|
||||
/// Builds content as slivers for the CustomScrollView
|
||||
List<Widget> _buildContentSlivers() {
|
||||
if (isLoading && _totalSize == 0 && _loadedItems.isEmpty) {
|
||||
if (isLoading && totalSize == 0 && loadedItems.isEmpty) {
|
||||
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
|
||||
}
|
||||
|
||||
if (errorMessage != null && _loadedItems.isEmpty) {
|
||||
if (errorMessage != null && loadedItems.isEmpty) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
child: ErrorStateWidget(
|
||||
@@ -1408,7 +1224,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
];
|
||||
}
|
||||
|
||||
if (_totalSize == 0 && !isLoading) {
|
||||
if (totalSize == 0 && !isLoading) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
|
||||
@@ -1436,7 +1252,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
|
||||
/// Builds either a sliver list or sliver grid based on the view mode
|
||||
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final itemCount = _totalSize;
|
||||
final itemCount = totalSize;
|
||||
final isPhone = _isPhone(context);
|
||||
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
|
||||
_effectiveTopPadding = topPadding;
|
||||
@@ -1497,12 +1313,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
||||
bool isLastColumn = false,
|
||||
bool disableScale = false,
|
||||
}) {
|
||||
final item = _loadedItems[index];
|
||||
final item = loadedItems[index];
|
||||
|
||||
// Show skeleton placeholder for unloaded items
|
||||
if (item == null) {
|
||||
_scheduleRangeLoad();
|
||||
return const _SkeletonCard();
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
|
||||
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
|
||||
@@ -1524,41 +1340,3 @@ 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 const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poster area — matches the Expanded poster in _buildGridCard
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
child: SkeletonLoader(child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
// Title bar
|
||||
SkeletonLoader(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||
child: SizedBox(height: 13, width: double.infinity),
|
||||
),
|
||||
SizedBox(height: 3),
|
||||
// Subtitle bar
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: 0.6,
|
||||
child: SkeletonLoader(borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox(height: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.getPlaylist(widget.playlist.ratingKey);
|
||||
return await client.fetchAllPlaylistItems(widget.playlist.ratingKey);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -599,26 +599,32 @@ class PlexClient {
|
||||
Map<String, String>? filters,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (start != null) queryParams['X-Plex-Container-Start'] = start;
|
||||
if (size != null) queryParams['X-Plex-Container-Size'] = size;
|
||||
|
||||
// Add filter parameters
|
||||
if (filters != null) {
|
||||
queryParams.addAll(filters);
|
||||
}
|
||||
|
||||
final queryParams = _buildPaginationParams(start, size);
|
||||
if (filters != null) queryParams.addAll(filters);
|
||||
final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
|
||||
|
||||
final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort);
|
||||
return _extractLibraryContentResult(response);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPaginationParams(int? start, int? size) {
|
||||
final params = <String, dynamic>{};
|
||||
if (start != null) params['X-Plex-Container-Start'] = start;
|
||||
if (size != null) params['X-Plex-Container-Size'] = size;
|
||||
return params;
|
||||
}
|
||||
|
||||
LibraryContentResult _extractLibraryContentResult(PlexResponse 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);
|
||||
}
|
||||
|
||||
Future<LibraryContentResult> _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}) async {
|
||||
final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort);
|
||||
return _extractLibraryContentResult(response);
|
||||
}
|
||||
|
||||
/// Parse list of PlexMetadata from a cached response
|
||||
List<PlexMetadata> _parseMetadataListFromCachedResponse(Map<String, dynamic> cached) {
|
||||
final metadataList = PlexCacheParser.extractMetadataList(cached);
|
||||
@@ -837,6 +843,33 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Page size for iterating all items via [_fetchAllPages]. Also the cap
|
||||
/// for endpoints that send `X-Plex-Container-Size` but aren't truly paginated
|
||||
/// (collections listing, playlists listing, search).
|
||||
static const int _defaultListContainerSize = 1000;
|
||||
|
||||
/// Page size used when walking all pages of a paginated endpoint.
|
||||
static const int _fetchAllPageSize = 200;
|
||||
|
||||
/// Iterate every page of a paginated endpoint and concatenate the results.
|
||||
/// Stops as soon as [LibraryContentResult.totalSize] is reached or a page
|
||||
/// returns no items. Errors propagate.
|
||||
Future<List<PlexMetadata>> _fetchAllPages(
|
||||
Future<LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, {
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final all = <PlexMetadata>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchPage(start, _fetchAllPageSize, abort);
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (page.items.isEmpty) break;
|
||||
if (start >= page.totalSize) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/// Parse audio/subtitle tracks and the video stream's frame rate from a
|
||||
/// raw Part.Stream list in a single pass.
|
||||
({List<PlexAudioTrack> audio, List<PlexSubtitleTrack> subtitles, double? frameRate}) _parseStreams(
|
||||
@@ -1042,6 +1075,7 @@ class PlexClient {
|
||||
'searchTypes': 'movies,tv',
|
||||
'includeCollections': 1,
|
||||
'includeExternalMedia': 1,
|
||||
'X-Plex-Container-Size': limit,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1712,21 +1746,23 @@ class PlexClient {
|
||||
}, 'Failed to get hub content');
|
||||
}
|
||||
|
||||
/// Get playlist content by playlist ID
|
||||
/// Returns the list of metadata items in the playlist
|
||||
Future<List<PlexMetadata>> getPlaylist(String playlistId) {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _http.get('/playlists/$playlistId/items'),
|
||||
_extractMetadataList,
|
||||
'Failed to get playlist',
|
||||
);
|
||||
}
|
||||
/// Get playlist content by playlist ID, paginated.
|
||||
Future<LibraryContentResult> getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) =>
|
||||
_fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort);
|
||||
|
||||
/// Fetch every page of a playlist's items. For callers that need the full list
|
||||
/// (downloads, sync rules, context-menu shuffle).
|
||||
Future<List<PlexMetadata>> fetchAllPlaylistItems(String playlistId) =>
|
||||
_fetchAllPages((start, size, abort) => getPlaylist(playlistId, start: start, size: size, abort: abort));
|
||||
|
||||
/// Get all playlists
|
||||
/// Filters by playlistType=video by default
|
||||
/// Set smart to true/false to filter smart playlists, or null for all
|
||||
Future<List<PlexPlaylist>> getPlaylists({String playlistType = 'video', bool? smart}) {
|
||||
final queryParams = <String, dynamic>{'playlistType': playlistType};
|
||||
final queryParams = <String, dynamic>{
|
||||
'playlistType': playlistType,
|
||||
'X-Plex-Container-Size': _defaultListContainerSize,
|
||||
};
|
||||
if (smart != null) {
|
||||
queryParams['smart'] = smart ? '1' : '0';
|
||||
}
|
||||
@@ -1965,7 +2001,10 @@ class PlexClient {
|
||||
/// Returns collections as PlexMetadata objects with type="collection"
|
||||
Future<List<PlexMetadata>> getLibraryCollections(String sectionId) async {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _http.get('/library/sections/$sectionId/collections', queryParameters: {'includeGuids': 1}),
|
||||
() => _http.get(
|
||||
'/library/sections/$sectionId/collections',
|
||||
queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize},
|
||||
),
|
||||
(response) {
|
||||
final allItems = _extractMetadataList(response);
|
||||
// Collections should have type="collection"
|
||||
@@ -1977,24 +2016,25 @@ class PlexClient {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get items in a collection
|
||||
/// Returns the list of metadata items in the collection
|
||||
Future<List<PlexMetadata>> getCollectionItems(String collectionId) {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _http.get('/library/collections/$collectionId/children'),
|
||||
_extractMetadataList,
|
||||
'Failed to get collection items',
|
||||
);
|
||||
}
|
||||
/// Get items in a collection, paginated.
|
||||
Future<LibraryContentResult> getCollectionItems(
|
||||
String collectionId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) => _fetchPaginatedList('/library/collections/$collectionId/children', start: start, size: size, abort: abort);
|
||||
|
||||
/// Get media featuring a specific person (actor/director)
|
||||
Future<List<PlexMetadata>> getPersonMedia(String personId) {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _http.get('/library/people/$personId/media'),
|
||||
_extractMetadataList,
|
||||
'Failed to get person media',
|
||||
);
|
||||
}
|
||||
/// Fetch every item in a collection (downloads, sync rules, context-menu shuffle).
|
||||
Future<List<PlexMetadata>> fetchAllCollectionItems(String collectionId) =>
|
||||
_fetchAllPages((start, size, abort) => getCollectionItems(collectionId, start: start, size: size, abort: abort));
|
||||
|
||||
/// Get media featuring a specific person (actor/director), paginated.
|
||||
Future<LibraryContentResult> getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
|
||||
_fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort);
|
||||
|
||||
/// Fetch every media item featuring a given person.
|
||||
Future<List<PlexMetadata>> fetchAllPersonMedia(String personId) =>
|
||||
_fetchAllPages((start, size, abort) => getPersonMedia(personId, start: start, size: size, abort: abort));
|
||||
|
||||
/// Delete a collection
|
||||
/// Deletes a library collection from the server
|
||||
|
||||
@@ -292,8 +292,8 @@ class SyncRuleExecutor {
|
||||
final List<PlexMetadata> rootItems;
|
||||
try {
|
||||
rootItems = rule.targetType == ContentTypes.collection
|
||||
? await client.getCollectionItems(rule.ratingKey)
|
||||
: await client.getPlaylist(rule.ratingKey);
|
||||
? await client.fetchAllCollectionItems(rule.ratingKey)
|
||||
: await client.fetchAllPlaylistItems(rule.ratingKey);
|
||||
} catch (e) {
|
||||
appLogger.w('Sync rule ${rule.globalKey}: failed to fetch list items: $e');
|
||||
return null;
|
||||
|
||||
@@ -1168,7 +1168,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final client = _getClientForItem();
|
||||
|
||||
try {
|
||||
final items = await client.getCollectionItems(collection.ratingKey);
|
||||
final items = await client.fetchAllCollectionItems(collection.ratingKey);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await showCollectionDownloadOptionsAndQueue(
|
||||
@@ -1200,7 +1200,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final client = _getClientForItem();
|
||||
|
||||
try {
|
||||
final items = await client.getPlaylist(playlist.ratingKey);
|
||||
final items = await client.fetchAllPlaylistItems(playlist.ratingKey);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final playlistMetadata = PlexMetadata(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'media_card.dart';
|
||||
|
||||
/// Placeholder that mirrors the poster + title + subtitle layout of a real
|
||||
/// media card. Rendered in a sparse grid while items for that slot are in
|
||||
/// flight. Not focusable — dpad navigation skips over these.
|
||||
class SkeletonMediaCard extends StatelessWidget {
|
||||
const SkeletonMediaCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
child: SkeletonLoader(child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
SkeletonLoader(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||
child: SizedBox(height: 13, width: double.infinity),
|
||||
),
|
||||
SizedBox(height: 3),
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: 0.6,
|
||||
child: SkeletonLoader(borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox(height: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user