mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(library): retain history during active downloads
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user