Files
SpotiFLAC-Mobile/lib/controllers/queue_library_pager.dart
T
zarzet 830dee31ed refactor(queue): move library paging state into QueueLibraryPager
The Library tab State held the page offsets, the load-more guard, the count
and page snapshot caches, the blank-library repair throttle and their trim
and scope rules inline. QueueLibraryPager in lib/controllers now owns that
state and logic; the State keeps one _pager field and forwards, passing
setState, mounted and the provider reloads as callbacks.

The page request and page data types implement two small interfaces so the
pager can compare scopes and test emptiness without seeing the private
types. Provider reads, page size, trim limits, the repair throttle and the
order of cache writes and setState calls are unchanged.
2026-10-19 01:12:21 +07:00

249 lines
7.2 KiB
Dart

import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/controllers/queue_library_refresh_policy.dart';
import 'package:spotiflac_android/services/library_database.dart'
show QueueLibraryCounts;
/// A paged Library query as seen by [QueueLibraryPager].
abstract interface class QueueLibraryPageKey<K> {
int get offset;
int get limit;
/// Whether [other] lists the same filtered and sorted Library rows, ignoring
/// the page offset and cursor.
bool hasSamePageScope(K other);
}
/// A loaded Library page as seen by [QueueLibraryPager].
abstract interface class QueueLibraryPageContent {
bool get isEmpty;
}
/// Page offsets and the count and page snapshots behind the Library tab.
///
/// Snapshots outlive provider reloads so a refresh, a metadata backfill or a
/// download completion never flashes the Library as empty. The owning State
/// passes its `setState` and `mounted` checks to the methods that rebuild.
class QueueLibraryPager<
C,
R extends QueueLibraryPageKey<R>,
P extends QueueLibraryPageContent
> {
QueueLibraryPager({
required this.pageSize,
required P Function(List<P> pages) combinePages,
}) : _combinePages = combinePages;
final int pageSize;
final P Function(List<P> pages) _combinePages;
final Map<String, int> _pageOffsetByFilter = {};
bool _pageLoadScheduled = false;
final Map<C, QueueLibraryCounts> _countsCache = {};
final Map<R, P> _pageDataCache = {};
DateTime? _lastBlankRepairAt;
int pageOffsetFor(String filterMode) => _pageOffsetByFilter[filterMode] ?? 0;
void resetOffsets() {
_pageOffsetByFilter.clear();
}
void resetPaging() {
resetOffsets();
_pageDataCache.clear();
}
void _loadMore({
required String filterMode,
required bool hasMoreLibrary,
required void Function(VoidCallback fn) setState,
}) {
if (_pageLoadScheduled) return;
_pageLoadScheduled = true;
setState(() {
if (hasMoreLibrary) {
_pageOffsetByFilter[filterMode] = pageOffsetFor(filterMode) + pageSize;
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_pageLoadScheduled = false;
});
}
QueueLibraryCounts resolveCounts(
AsyncValue<QueueLibraryCounts> value,
C request, {
QueueLibraryCounts? nonEmptyFallback,
}) {
return value.maybeWhen(
data: (counts) {
final cached = _countsCache[request];
final resolved = resolveQueueLibraryCountsSnapshot(
current: counts,
cached: cached,
activeDownloadFallback: nonEmptyFallback,
);
_countsCache[request] = resolved;
_trimCountsCache();
return resolved;
},
orElse: () =>
_countsCache[request] ??
nonEmptyFallback ??
const QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
singleTrackCount: 0,
),
);
}
/// [nonEmptyFallback] is non-null when an in-memory snapshot is available;
/// it is only invoked when no cached or live page has content.
P resolvePageData(
AsyncValue<P>? value,
R request, {
P Function()? nonEmptyFallback,
}) {
void storePage(P data) {
final cached = cachedPageAt(request, request.offset);
if (shouldRetainQueueLibraryPageSnapshot(
currentIsEmpty: data.isEmpty,
cachedHasContent: cached != null && !cached.isEmpty,
activeDownloadFallbackAvailable: nonEmptyFallback != null,
)) {
return;
}
final staleRequests = _pageDataCache.keys
.where(
(cachedRequest) =>
cachedRequest.offset == request.offset &&
cachedRequest != request &&
cachedRequest.hasSamePageScope(request),
)
.toList(growable: false);
for (final staleRequest in staleRequests) {
_pageDataCache.remove(staleRequest);
}
_pageDataCache[request] = data;
_trimPageDataCache(protectedRequest: request);
}
if (value != null) {
final liveData = value.asData?.value;
if (liveData != null) {
storePage(liveData);
}
value.whenOrNull(data: storePage);
}
final pages = <P>[];
for (var offset = 0; offset <= request.offset; offset += pageSize) {
final page = cachedPageAt(request, offset);
if (page != null) pages.add(page);
}
final combined = _combinePages(pages);
if (combined.isEmpty && nonEmptyFallback != null) {
return nonEmptyFallback();
}
return combined;
}
void _invalidateDataCaches() {
_countsCache.clear();
_pageDataCache.clear();
}
/// Clears the snapshots and runs [repair] after the frame when the queue
/// has items but the Library shows nothing, at most once per 8 seconds.
void scheduleBlankLibraryRepair({
required bool hasQueueItems,
required bool hasLibraryContent,
required bool hasAnyLibraryItems,
required bool isLibraryPageLoading,
required bool Function() isMounted,
required VoidCallback repair,
}) {
if (!hasQueueItems ||
hasLibraryContent ||
hasAnyLibraryItems ||
isLibraryPageLoading) {
return;
}
final now = DateTime.now();
final last = _lastBlankRepairAt;
if (last != null && now.difference(last) < const Duration(seconds: 8)) {
return;
}
_lastBlankRepairAt = now;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!isMounted()) return;
_invalidateDataCaches();
repair();
});
}
void _trimCountsCache() {
const maxCountEntries = 24;
while (_countsCache.length > maxCountEntries) {
_countsCache.remove(_countsCache.keys.first);
}
}
bool _isProtectedPage(R request, R protectedRequest) {
return request.hasSamePageScope(protectedRequest) &&
request.limit == protectedRequest.limit &&
request.offset <= protectedRequest.offset;
}
P? cachedPageAt(R request, int offset) {
for (final entry in _pageDataCache.entries) {
if (entry.key.offset == offset && entry.key.hasSamePageScope(request)) {
return entry.value;
}
}
return null;
}
void _trimPageDataCache({required R protectedRequest}) {
const maxPageEntries = 96;
while (_pageDataCache.length > maxPageEntries) {
final removableKey = _pageDataCache.keys
.where((request) => !_isProtectedPage(request, protectedRequest))
.firstOrNull;
if (removableKey == null) break;
_pageDataCache.remove(removableKey);
}
}
bool handleScrollNotification({
required ScrollNotification notification,
required String filterMode,
required bool hasMoreLibrary,
required bool isPageLoading,
required void Function(VoidCallback fn) setState,
}) {
if (isPageLoading || !hasMoreLibrary || notification.depth != 0) {
return false;
}
final metrics = notification.metrics;
if (metrics.maxScrollExtent <= 0) return false;
final threshold = metrics.maxScrollExtent * 0.7;
final nearEnd =
metrics.pixels >= threshold ||
metrics.extentAfter <= metrics.viewportDimension * 1.5;
if (!nearEnd) return false;
_loadMore(
filterMode: filterMode,
hasMoreLibrary: hasMoreLibrary,
setState: setState,
);
return false;
}
}