Files
plezy/lib/mixins/paginated_item_loader.dart
T
2026-05-01 01:20:36 +02:00

252 lines
9.5 KiB
Dart

import 'dart:async';
import 'package:flutter/widgets.dart';
import '../media/library_query.dart';
import '../media/media_item.dart';
import '../utils/media_server_http_client.dart';
import '../exceptions/media_server_exceptions.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, MediaItem> 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 client method that returns a [LibraryPage] of [MediaItem].
Future<LibraryPage<MediaItem>> 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<MediaItem> 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<LibraryPage<MediaItem>> 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.totalCount;
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, MediaItem>{};
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.totalCount != totalSize) totalSize = result.totalCount;
});
_retryCount = 0;
onPageLoaded(start, result.items);
return true;
} catch (e) {
if (e is MediaServerHttpException && e.type == MediaServerHttpErrorType.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);
}
}
}