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.
This commit is contained in:
zarzet committed 2026-10-19 01:12:21 +07:00
1 parent db4643c26e
commit 830dee31ed
3 files changed
+301 -223

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
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;
}
}
+35 -221
View File
@@ -81,6 +81,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/screens/mornye_library_screen.dart';
import 'package:spotiflac_android/controllers/queue_library_pager.dart';
import 'package:spotiflac_android/controllers/queue_library_refresh_policy.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -214,14 +215,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String _libraryQualityLabelMode = AppSettings.libraryQualityLabelBitDepthOnly;
double _libraryGridExtent = _libraryGridDefaultExtent;
double? _libraryGridPinchStartExtent;
final Map<String, int> _libraryPageOffsetByFilter = {};
bool _libraryPageLoadScheduled = false;
final Map<_QueueLibraryCountsRequest, QueueLibraryCounts>
_queueLibraryCountsCache = {};
final Map<_QueueLibraryPageRequest, _QueueLibraryPageData>
_queueLibraryPageDataCache = {};
DateTime? _lastBlankLibraryRepairAt;
final QueueLibraryPager<
_QueueLibraryCountsRequest,
_QueueLibraryPageRequest,
_QueueLibraryPageData
>
_pager = QueueLibraryPager(
pageSize: _libraryPageSize,
combinePages: _QueueLibraryPageData.combine,
);
double _queueCoverSize() {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
@@ -355,119 +358,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
});
}
int _libraryPageOffsetFor(String filterMode) =>
_libraryPageOffsetByFilter[filterMode] ?? 0;
void _resetLibraryOffsets() {
_libraryPageOffsetByFilter.clear();
}
void _resetLibraryPaging() {
_resetLibraryOffsets();
_queueLibraryPageDataCache.clear();
}
void _loadMoreLibraryItems({
required String filterMode,
required bool hasMoreLibrary,
}) {
if (_libraryPageLoadScheduled) return;
_libraryPageLoadScheduled = true;
setState(() {
if (hasMoreLibrary) {
_libraryPageOffsetByFilter[filterMode] =
_libraryPageOffsetFor(filterMode) + _libraryPageSize;
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_libraryPageLoadScheduled = false;
});
}
QueueLibraryCounts _resolveQueueLibraryCounts(
AsyncValue<QueueLibraryCounts> value,
_QueueLibraryCountsRequest request, {
QueueLibraryCounts? nonEmptyFallback,
}) {
return value.maybeWhen(
data: (counts) {
final cached = _queueLibraryCountsCache[request];
final resolved = resolveQueueLibraryCountsSnapshot(
current: counts,
cached: cached,
activeDownloadFallback: nonEmptyFallback,
);
_queueLibraryCountsCache[request] = resolved;
_trimQueueLibraryCountsCache();
return resolved;
},
orElse: () =>
_queueLibraryCountsCache[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.
_QueueLibraryPageData _resolveQueueLibraryPageData(
AsyncValue<_QueueLibraryPageData>? value,
_QueueLibraryPageRequest request, {
_QueueLibraryPageData Function()? nonEmptyFallback,
}) {
void storePage(_QueueLibraryPageData data) {
final cached = _cachedQueueLibraryPageAt(request, request.offset);
if (shouldRetainQueueLibraryPageSnapshot(
currentIsEmpty: data.isEmpty,
cachedHasContent: cached != null && !cached.isEmpty,
activeDownloadFallbackAvailable: nonEmptyFallback != null,
)) {
return;
}
final staleRequests = _queueLibraryPageDataCache.keys
.where(
(cachedRequest) =>
cachedRequest.offset == request.offset &&
cachedRequest != request &&
_sameQueueLibraryPageScope(cachedRequest, request),
)
.toList(growable: false);
for (final staleRequest in staleRequests) {
_queueLibraryPageDataCache.remove(staleRequest);
}
_queueLibraryPageDataCache[request] = data;
_trimQueueLibraryPageDataCache(protectedRequest: request);
}
if (value != null) {
final liveData = value.asData?.value;
if (liveData != null) {
storePage(liveData);
}
value.whenOrNull(data: storePage);
}
final pages = <_QueueLibraryPageData>[];
for (var offset = 0; offset <= request.offset; offset += _libraryPageSize) {
final page = _cachedQueueLibraryPageAt(request, offset);
if (page != null) pages.add(page);
}
final combined = _QueueLibraryPageData.combine(pages);
if (combined.isEmpty && nonEmptyFallback != null) {
return nonEmptyFallback();
}
return combined;
}
void _invalidateLibraryDataCaches() {
_queueLibraryCountsCache.clear();
_queueLibraryPageDataCache.clear();
}
void _resetLibraryPaging() => _pager.resetPaging();
void _scheduleBlankLibraryRepair({
required bool hasQueueItems,
@@ -475,87 +366,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required bool hasAnyLibraryItems,
required bool isLibraryPageLoading,
}) {
if (!hasQueueItems ||
hasLibraryContent ||
hasAnyLibraryItems ||
isLibraryPageLoading) {
return;
}
final now = DateTime.now();
final last = _lastBlankLibraryRepairAt;
if (last != null && now.difference(last) < const Duration(seconds: 8)) {
return;
}
_lastBlankLibraryRepairAt = now;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_invalidateLibraryDataCaches();
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
if (ref.read(settingsProvider).localLibraryEnabled) {
ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
setState(() {});
});
}
void _trimQueueLibraryCountsCache() {
const maxCountEntries = 24;
while (_queueLibraryCountsCache.length > maxCountEntries) {
_queueLibraryCountsCache.remove(_queueLibraryCountsCache.keys.first);
}
}
bool _isProtectedQueueLibraryPage(
_QueueLibraryPageRequest request,
_QueueLibraryPageRequest protectedRequest,
) {
return _sameQueueLibraryPageScope(request, protectedRequest) &&
request.limit == protectedRequest.limit &&
request.offset <= protectedRequest.offset;
}
bool _sameQueueLibraryPageScope(
_QueueLibraryPageRequest a,
_QueueLibraryPageRequest b,
) {
return a.filterMode == b.filterMode &&
a.limit == b.limit &&
a.searchQuery == b.searchQuery &&
a.filterSource == b.filterSource &&
a.filterQuality == b.filterQuality &&
a.filterFormat == b.filterFormat &&
a.filterMetadata == b.filterMetadata &&
a.sortMode == b.sortMode &&
a.localLibraryEnabled == b.localLibraryEnabled;
}
_QueueLibraryPageData? _cachedQueueLibraryPageAt(
_QueueLibraryPageRequest request,
int offset,
) {
for (final entry in _queueLibraryPageDataCache.entries) {
if (entry.key.offset == offset &&
_sameQueueLibraryPageScope(entry.key, request)) {
return entry.value;
}
}
return null;
}
void _trimQueueLibraryPageDataCache({
required _QueueLibraryPageRequest protectedRequest,
}) {
const maxPageEntries = 96;
while (_queueLibraryPageDataCache.length > maxPageEntries) {
final removableKey = _queueLibraryPageDataCache.keys
.where(
(request) =>
!_isProtectedQueueLibraryPage(request, protectedRequest),
)
.firstOrNull;
if (removableKey == null) break;
_queueLibraryPageDataCache.remove(removableKey);
}
_pager.scheduleBlankLibraryRepair(
hasQueueItems: hasQueueItems,
hasLibraryContent: hasLibraryContent,
hasAnyLibraryItems: hasAnyLibraryItems,
isLibraryPageLoading: isLibraryPageLoading,
isMounted: () => mounted,
repair: () {
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
if (ref.read(settingsProvider).localLibraryEnabled) {
ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
setState(() {});
},
);
}
bool _handleLibraryScrollNotification({
@@ -564,23 +388,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required bool hasMoreLibrary,
required bool isPageLoading,
}) {
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;
_loadMoreLibraryItems(
return _pager.handleScrollNotification(
notification: notification,
filterMode: filterMode,
hasMoreLibrary: hasMoreLibrary,
isPageLoading: isPageLoading,
setState: setState,
);
return false;
}
void _onFilterPageChanged(int index) {
@@ -745,7 +559,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
// The family provider already reruns for the new revision. Retain its
// last successful page while SQLite is loading so metadata backfills
// and download completions cannot flash the Library as empty.
_resetLibraryOffsets();
_pager.resetOffsets();
if (mounted) setState(() {});
},
);
@@ -755,7 +569,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
(previous, next) {
if (previous == null || previous == next) return;
// Keep stale rows visible until the refreshed query replaces them.
_resetLibraryOffsets();
_pager.resetOffsets();
if (mounted) setState(() {});
},
);
@@ -867,14 +681,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
persistedTotalCount: historyTotalCount,
)
: null;
final queueCounts = _resolveQueueLibraryCounts(
final queueCounts = _pager.resolveCounts(
countsValue,
countsRequest,
nonEmptyFallback: historySnapshotCounts,
);
_QueueLibraryPageRequest pageRequest(String filterMode) {
final offset = _libraryPageOffsetFor(filterMode);
final offset = _pager.pageOffsetFor(filterMode);
final baseRequest = _QueueLibraryPageRequest(
filterMode: filterMode,
limit: _libraryPageSize,
@@ -888,7 +702,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
localLibraryEnabled: localLibraryEnabled,
);
if (offset == 0) return baseRequest;
final previousPage = _cachedQueueLibraryPageAt(
final previousPage = _pager.cachedPageAt(
baseRequest,
offset - _libraryPageSize,
);
@@ -925,7 +739,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
hasQueueItems &&
inMemoryHistoryItems.isNotEmpty &&
request.allowsInMemoryHistoryFallback;
return _resolveQueueLibraryPageData(
return _pager.resolvePageData(
filterMode == historyFilterMode ? activePageValue : null,
request,
nonEmptyFallback: historySnapshotAvailable
+18 -2
View File
@@ -84,9 +84,12 @@ class _FilterContentData {
filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length;
}
class _QueueLibraryPageRequest {
class _QueueLibraryPageRequest
implements QueueLibraryPageKey<_QueueLibraryPageRequest> {
final String filterMode;
@override
final int limit;
@override
final int offset;
final String searchQuery;
final String? filterSource;
@@ -137,6 +140,18 @@ class _QueueLibraryPageRequest {
filterMode == 'albums' ||
filterMode == 'singles');
@override
bool hasSamePageScope(_QueueLibraryPageRequest other) =>
filterMode == other.filterMode &&
limit == other.limit &&
searchQuery == other.searchQuery &&
filterSource == other.filterSource &&
filterQuality == other.filterQuality &&
filterFormat == other.filterFormat &&
filterMetadata == other.filterMetadata &&
sortMode == other.sortMode &&
localLibraryEnabled == other.localLibraryEnabled;
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -224,7 +239,7 @@ class _QueueLibraryCountsRequest {
);
}
class _QueueLibraryPageData {
class _QueueLibraryPageData implements QueueLibraryPageContent {
final List<UnifiedLibraryItem> items;
final List<DownloadHistoryItem> historyItems;
final List<LocalLibraryItem> localItems;
@@ -241,6 +256,7 @@ class _QueueLibraryPageData {
this.nextCursor,
});
@override
bool get isEmpty =>
items.isEmpty &&
historyItems.isEmpty &&