diff --git a/lib/screens/queue_library_refresh_policy.dart b/lib/screens/queue_library_refresh_policy.dart new file mode 100644 index 00000000..40b9142b --- /dev/null +++ b/lib/screens/queue_library_refresh_policy.dart @@ -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; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 2cc77480..4dcf8296 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { QueueLibraryCounts _resolveQueueLibraryCounts( AsyncValue 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 { _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 { 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 { 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 { 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 { queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0; final hasLibraryContent = historyTotalCount > 0 || + inMemoryHistoryItems.isNotEmpty || (localLibraryEnabled && localLibraryTotalCount > 0); final hasActiveSearch = _searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty; diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index 8a4bd5a8..247df822 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -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 historyItems, { + required String filterMode, + required int limit, + }) { + final albumTracks = >{}; + 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 items, { + required int persistedTotalCount, +}) { + final albumCounts = {}; + 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, diff --git a/test/queue_library_refresh_policy_test.dart b/test/queue_library_refresh_policy_test.dart new file mode 100644 index 00000000..f31dae74 --- /dev/null +++ b/test/queue_library_refresh_policy_test.dart @@ -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, + ); + }); +}