fix(library): retain history during active downloads

This commit is contained in:
zarzet
2026-07-28 18:12:55 +07:00
parent 7eb0ed2fdb
commit b2e4548204
4 changed files with 290 additions and 16 deletions
@@ -0,0 +1,27 @@
import 'package:spotiflac_android/services/library_database.dart';
bool queueLibraryCountsHaveContent(QueueLibraryCounts counts) =>
counts.allTrackCount > 0 ||
counts.albumCount > 0 ||
counts.singleTrackCount > 0;
QueueLibraryCounts resolveQueueLibraryCountsSnapshot({
required QueueLibraryCounts current,
QueueLibraryCounts? cached,
QueueLibraryCounts? activeDownloadFallback,
}) {
if (queueLibraryCountsHaveContent(current) ||
activeDownloadFallback == null) {
return current;
}
if (cached != null && queueLibraryCountsHaveContent(cached)) {
return cached;
}
return activeDownloadFallback;
}
bool shouldRetainQueueLibraryPageSnapshot({
required bool currentIsEmpty,
required bool cachedHasContent,
required bool activeDownloadFallbackAvailable,
}) => currentIsEmpty && cachedHasContent && activeDownloadFallbackAvailable;
+63 -16
View File
@@ -42,6 +42,7 @@ import 'package:cached_network_image/cached_network_image.dart';
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/queue_library_refresh_policy.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -440,16 +441,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
QueueLibraryCounts _resolveQueueLibraryCounts(
AsyncValue<QueueLibraryCounts> value,
_QueueLibraryCountsRequest request,
) {
_QueueLibraryCountsRequest request, {
QueueLibraryCounts? nonEmptyFallback,
}) {
return value.maybeWhen(
data: (counts) {
_queueLibraryCountsCache[request] = counts;
final cached = _queueLibraryCountsCache[request];
final resolved = resolveQueueLibraryCountsSnapshot(
current: counts,
cached: cached,
activeDownloadFallback: nonEmptyFallback,
);
_queueLibraryCountsCache[request] = resolved;
_trimQueueLibraryCountsCache();
return counts;
return resolved;
},
orElse: () =>
_queueLibraryCountsCache[request] ??
nonEmptyFallback ??
const QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
@@ -460,20 +469,28 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_QueueLibraryPageData _resolveQueueLibraryPageData(
AsyncValue<_QueueLibraryPageData>? value,
_QueueLibraryPageRequest request,
) {
_QueueLibraryPageRequest request, {
_QueueLibraryPageData? nonEmptyFallback,
}) {
void storePage(_QueueLibraryPageData data) {
final cached = _queueLibraryPageDataCache[request];
if (shouldRetainQueueLibraryPageSnapshot(
currentIsEmpty: data.isEmpty,
cachedHasContent: cached != null && !cached.isEmpty,
activeDownloadFallbackAvailable: nonEmptyFallback != null,
)) {
return;
}
_queueLibraryPageDataCache[request] = data;
_trimQueueLibraryPageDataCache(protectedRequest: request);
}
if (value != null) {
final liveData = value.asData?.value;
if (liveData != null) {
_queueLibraryPageDataCache[request] = liveData;
_trimQueueLibraryPageDataCache(protectedRequest: request);
storePage(liveData);
}
value.whenOrNull(
data: (data) {
_queueLibraryPageDataCache[request] = data;
_trimQueueLibraryPageDataCache(protectedRequest: request);
},
);
value.whenOrNull(data: storePage);
}
final pages = <_QueueLibraryPageData>[];
@@ -494,7 +511,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (page != null) pages.add(page);
}
return _QueueLibraryPageData.combine(pages);
final combined = _QueueLibraryPageData.combine(pages);
if (combined.isEmpty && nonEmptyFallback != null) {
return nonEmptyFallback;
}
return combined;
}
void _invalidateLibraryDataCaches() {
@@ -1261,7 +1282,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
localLibraryEnabled: localLibraryEnabled,
);
final countsValue = ref.watch(_queueLibraryCountsProvider(countsRequest));
final queueCounts = _resolveQueueLibraryCounts(countsValue, countsRequest);
final historySnapshotFallbackEnabled =
hasQueueItems &&
inMemoryHistoryItems.isNotEmpty &&
countsRequest.allowsInMemoryHistoryFallback;
final historySnapshotCounts = historySnapshotFallbackEnabled
? _historySnapshotCounts(
inMemoryHistoryItems,
persistedTotalCount: historyTotalCount,
)
: null;
final queueCounts = _resolveQueueLibraryCounts(
countsValue,
countsRequest,
nonEmptyFallback: historySnapshotCounts,
);
_QueueLibraryPageRequest pageRequest(String filterMode) =>
_QueueLibraryPageRequest(
@@ -1286,9 +1321,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final request = filterMode == historyFilterMode
? activePageRequest
: pageRequest(filterMode);
final historySnapshot =
hasQueueItems &&
inMemoryHistoryItems.isNotEmpty &&
request.allowsInMemoryHistoryFallback
? _QueueLibraryPageData.fromHistorySnapshot(
inMemoryHistoryItems,
filterMode: request.filterMode,
limit: request.limit,
)
: null;
return _resolveQueueLibraryPageData(
filterMode == historyFilterMode ? activePageValue : null,
request,
nonEmptyFallback: historySnapshot,
);
}
@@ -1322,6 +1368,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0;
final hasLibraryContent =
historyTotalCount > 0 ||
inMemoryHistoryItems.isNotEmpty ||
(localLibraryEnabled && localLibraryTotalCount > 0);
final hasActiveSearch =
_searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty;
+124
View File
@@ -116,6 +116,18 @@ class _QueueLibraryPageRequest {
includeLocal: localLibraryEnabled,
);
bool get allowsInMemoryHistoryFallback =>
offset == 0 &&
searchQuery.trim().isEmpty &&
filterSource != 'local' &&
filterQuality == null &&
filterFormat == null &&
filterMetadata == null &&
sortMode == 'latest' &&
(filterMode == 'all' ||
filterMode == 'albums' ||
filterMode == 'singles');
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -172,6 +184,13 @@ class _QueueLibraryCountsRequest {
includeLocal: localLibraryEnabled,
);
bool get allowsInMemoryHistoryFallback =>
searchQuery.trim().isEmpty &&
filterSource != 'local' &&
filterQuality == null &&
filterFormat == null &&
filterMetadata == null;
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -209,6 +228,85 @@ class _QueueLibraryPageData {
this.groupedLocalAlbums = const [],
});
bool get isEmpty =>
items.isEmpty &&
historyItems.isEmpty &&
localItems.isEmpty &&
groupedAlbums.isEmpty &&
groupedLocalAlbums.isEmpty;
factory _QueueLibraryPageData.fromHistorySnapshot(
List<DownloadHistoryItem> historyItems, {
required String filterMode,
required int limit,
}) {
final albumTracks = <String, List<DownloadHistoryItem>>{};
for (final item in historyItems) {
final albumKey = LibraryDatabase.albumKeyFor(
item.albumName,
item.albumArtist,
item.artistName,
);
albumTracks.putIfAbsent(albumKey, () => []).add(item);
}
if (filterMode == 'albums') {
final albums = <_GroupedAlbum>[];
for (final tracks in albumTracks.values) {
if (tracks.length <= 1) continue;
final latest = tracks
.map((item) => item.downloadedAt)
.reduce((a, b) => a.isAfter(b) ? a : b);
final sample = tracks.first;
String? coverUrl;
for (final track in tracks) {
final candidate = track.coverUrl?.trim();
if (candidate != null && candidate.isNotEmpty) {
coverUrl = candidate;
break;
}
}
albums.add(
_GroupedAlbum(
albumName: sample.albumName,
artistName: sample.albumArtist?.trim().isNotEmpty == true
? sample.albumArtist!
: sample.artistName,
coverUrl: coverUrl,
sampleFilePath: sample.filePath,
tracks: List.unmodifiable(tracks),
trackCount: tracks.length,
latestDownload: latest,
),
);
}
albums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
return _QueueLibraryPageData(
groupedAlbums: albums.take(limit).toList(growable: false),
);
}
final selectedHistory =
(filterMode == 'singles'
? historyItems.where((item) {
final albumKey = LibraryDatabase.albumKeyFor(
item.albumName,
item.albumArtist,
item.artistName,
);
return albumTracks[albumKey]?.length == 1;
})
: historyItems)
.take(limit)
.toList(growable: false);
return _QueueLibraryPageData(
items: selectedHistory
.map(UnifiedLibraryItem.fromDownloadHistory)
.toList(growable: false),
historyItems: selectedHistory,
);
}
factory _QueueLibraryPageData.combine(List<_QueueLibraryPageData> pages) {
if (pages.isEmpty) return const _QueueLibraryPageData();
if (pages.length == 1) return pages.first;
@@ -262,6 +360,32 @@ class _QueueLibraryPageData {
}
}
QueueLibraryCounts _historySnapshotCounts(
List<DownloadHistoryItem> items, {
required int persistedTotalCount,
}) {
final albumCounts = <String, int>{};
for (final item in items) {
final key = LibraryDatabase.albumKeyFor(
item.albumName,
item.albumArtist,
item.artistName,
);
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
}
final albumCount = albumCounts.values.where((count) => count > 1).length;
final singleTrackCount = albumCounts.values
.where((count) => count == 1)
.length;
return QueueLibraryCounts(
allTrackCount: persistedTotalCount > items.length
? persistedTotalCount
: items.length,
albumCount: albumCount,
singleTrackCount: singleTrackCount,
);
}
final _queueLibraryPageProvider = FutureProvider.autoDispose
.family<_QueueLibraryPageData, _QueueLibraryPageRequest>((
ref,
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart';
import 'package:spotiflac_android/services/library_database.dart';
void main() {
const empty = QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
singleTrackCount: 0,
);
const cached = QueueLibraryCounts(
allTrackCount: 20,
albumCount: 2,
singleTrackCount: 3,
);
const fallback = QueueLibraryCounts(
allTrackCount: 15,
albumCount: 1,
singleTrackCount: 4,
);
test('keeps cached counts when a download refresh returns empty', () {
final resolved = resolveQueueLibraryCountsSnapshot(
current: empty,
cached: cached,
activeDownloadFallback: fallback,
);
expect(identical(resolved, cached), isTrue);
});
test('uses in-memory history when no page cache exists yet', () {
final resolved = resolveQueueLibraryCountsSnapshot(
current: empty,
activeDownloadFallback: fallback,
);
expect(identical(resolved, fallback), isTrue);
});
test('allows a legitimate empty refresh without an active fallback', () {
final resolved = resolveQueueLibraryCountsSnapshot(
current: empty,
cached: cached,
);
expect(identical(resolved, empty), isTrue);
expect(
shouldRetainQueueLibraryPageSnapshot(
currentIsEmpty: true,
cachedHasContent: true,
activeDownloadFallbackAvailable: false,
),
isFalse,
);
});
test('retains a non-empty page only while its fallback is available', () {
expect(
shouldRetainQueueLibraryPageSnapshot(
currentIsEmpty: true,
cachedHasContent: true,
activeDownloadFallbackAvailable: true,
),
isTrue,
);
expect(
shouldRetainQueueLibraryPageSnapshot(
currentIsEmpty: false,
cachedHasContent: true,
activeDownloadFallbackAvailable: true,
),
isFalse,
);
});
}