From 83d7106e35cd7d1a6eda3f82a59728d6d023af40 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 6 May 2026 12:08:53 +0700 Subject: [PATCH] fix: distinguish preparing from downloading in native worker progress Prevent premature 'downloading' status before actual byte transfer starts, and cache async provider values to avoid UI flicker during queue library reloads. Progress pipeline: - StartItemProgress now initializes with 'preparing' status instead of 'downloading' - SetItemProgress ignores synthetic pre-download progress updates while status is still 'preparing' (no byte data yet) - DownloadService reads backend status field and propagates preparing/ downloading/finalizing to native worker item snapshot - Dart progress stream maps 'preparing' to DownloadStatus.downloading with progress 0.0 (indeterminate spinner) Queue tab: - Add _queueLibraryCountsCache and _queueLibraryPageDataCache to retain last successful data during FutureProvider refetches - Prevents empty-state flash when loadedIndexVersion bumps trigger provider invalidation - Caches trimmed to max 24 entries via FIFO eviction --- .../com/zarz/spotiflac/DownloadService.kt | 26 ++++++- go_backend/progress.go | 13 +++- go_backend/progress_test.go | 17 +++- lib/providers/download_queue_provider.dart | 15 ++-- lib/screens/queue_tab.dart | 78 +++++++++++++++---- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index a504fcdf..3f037a96 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -564,12 +564,12 @@ class DownloadService : Service() { nativeWorkerCurrentItemId = request.itemId currentTrackName = request.trackName currentArtistName = request.artistName - currentStatus = "downloading" + currentStatus = "preparing" lastProgress = 0L lastTotal = 0L updateNotification(0, 0) updateNativeWorkerItem(request.itemId) { - it.status = "downloading" + it.status = "preparing" it.progress = 0.0 it.bytesReceived = 0L it.bytesTotal = 0L @@ -580,7 +580,7 @@ class DownloadService : Service() { isRunning = true, isPaused = false, currentItemId = request.itemId, - message = "Downloading", + message = "Preparing", settingsJson = settingsJson, includeItems = true ) @@ -1100,14 +1100,34 @@ class DownloadService : Service() { val root = JSONObject(raw) val items = root.optJSONObject("items") ?: return val progress = items.optJSONObject(itemId) ?: return + val backendStatus = progress.optString("status", "downloading") val bytesReceived = progress.optLong("bytes_received", 0L) val bytesTotal = progress.optLong("bytes_total", 0L) + if (backendStatus == "preparing") { + currentStatus = "preparing" + updateNativeWorkerItem(itemId) { + it.status = "preparing" + it.progress = 0.0 + it.bytesReceived = 0L + it.bytesTotal = 0L + } + lastProgress = 0L + lastTotal = 0L + updateNotification(0L, 0L) + return + } val progressValue = if (bytesTotal > 0L) { bytesReceived.toDouble() / bytesTotal.toDouble() } else { progress.optDouble("progress", 0.0) }.coerceIn(0.0, 1.0) + currentStatus = if (backendStatus == "finalizing") { + "finalizing" + } else { + "downloading" + } updateNativeWorkerItem(itemId) { + it.status = currentStatus it.progress = progressValue it.bytesReceived = bytesReceived it.bytesTotal = bytesTotal diff --git a/go_backend/progress.go b/go_backend/progress.go index 5837dfb4..f527f1aa 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -213,8 +213,8 @@ func StartItemProgress(itemID string) { BytesTotal: 0, BytesReceived: 0, Progress: 0, - IsDownloading: true, - Status: itemProgressStatusDownloading, + IsDownloading: false, + Status: itemProgressStatusPreparing, revision: nextMultiProgressSeqLocked(), } delete(removedProgressSeq, itemID) @@ -316,14 +316,19 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal if item, ok := multiProgress.Items[itemID]; ok { before := itemProgressBridgeState(item) - item.Progress = progress + hasByteProgress := bytesReceived > 0 || bytesTotal > 0 + if item.Status != itemProgressStatusPreparing || hasByteProgress || progress >= 1 { + item.Progress = progress + } else { + item.Progress = 0 + } if bytesReceived > 0 { item.BytesReceived = bytesReceived } if bytesTotal > 0 { item.BytesTotal = bytesTotal } - if progress > 0 || bytesReceived > 0 || bytesTotal > 0 { + if hasByteProgress || progress >= 1 || item.Status == itemProgressStatusDownloading { item.IsDownloading = true item.Status = itemProgressStatusDownloading } diff --git a/go_backend/progress_test.go b/go_backend/progress_test.go index 7390107b..214509aa 100644 --- a/go_backend/progress_test.go +++ b/go_backend/progress_test.go @@ -24,11 +24,13 @@ func TestItemProgressPreparingAndDownloadingStatuses(t *testing.T) { } } - SetItemProgress(itemID, 0.37, 0, 0) + SetItemProgress(itemID, 0.05, 0, 0) if item := multiProgress.Items[itemID]; item == nil { t.Fatal("expected item progress entry to exist after update") - } else if item.Status != itemProgressStatusDownloading { - t.Fatalf("status after progress update = %q, want %q", item.Status, itemProgressStatusDownloading) + } else if item.Status != itemProgressStatusPreparing { + t.Fatalf("status after synthetic pre-download progress = %q, want %q", item.Status, itemProgressStatusPreparing) + } else if item.Progress != 0 { + t.Fatalf("progress after synthetic pre-download progress = %v, want 0", item.Progress) } SetItemDownloading(itemID) @@ -37,6 +39,15 @@ func TestItemProgressPreparingAndDownloadingStatuses(t *testing.T) { } else if item.Status != itemProgressStatusDownloading { t.Fatalf("status after download start = %q, want %q", item.Status, itemProgressStatusDownloading) } + + SetItemProgress(itemID, 0.37, 0, 0) + if item := multiProgress.Items[itemID]; item == nil { + t.Fatal("expected item progress entry to exist after real update") + } else if item.Status != itemProgressStatusDownloading { + t.Fatalf("status after real progress update = %q, want %q", item.Status, itemProgressStatusDownloading) + } else if item.Progress != 0.37 { + t.Fatalf("progress after real update = %v, want 0.37", item.Progress) + } } func TestItemProgressFinalizingAndCompletedStatuses(t *testing.T) { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 08a522a8..02381976 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2099,7 +2099,8 @@ class DownloadQueueNotifier extends Notifier { final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; final hasRealProgress = - bytesReceived > 0 || bytesTotal > 0 || progressFromBackend > 0; + status != 'preparing' && + (bytesReceived > 0 || bytesTotal > 0 || progressFromBackend > 0); if (status == 'finalizing') { progressUpdates[itemId] = const _ProgressUpdate( @@ -2112,7 +2113,7 @@ class DownloadQueueNotifier extends Notifier { continue; } - if (status == 'preparing' && !hasRealProgress) { + if (status == 'preparing') { progressUpdates[itemId] = const _ProgressUpdate( status: DownloadStatus.downloading, progress: 0.0, @@ -2264,10 +2265,7 @@ class DownloadQueueNotifier extends Notifier { final progressPercent = (selectedProgress['progress'] as num?)?.toDouble() ?? 0.0; - final hasRealProgress = - bytesReceived > 0 || bytesTotal > 0 || progressPercent > 0; - - if (backendStatus == 'preparing' && !hasRealProgress) { + if (backendStatus == 'preparing') { notifProgress = 0; notifTotal = 0; } else if (bytesTotal <= 0) { @@ -5400,6 +5398,11 @@ class DownloadQueueNotifier extends Notifier { continue; } + if (status == 'preparing') { + updateItemStatus(itemId, DownloadStatus.downloading, progress: 0.0); + continue; + } + if (status == 'downloading') { updateItemStatus( itemId, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 15d978b2..d26b17fa 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -150,6 +150,10 @@ class _QueueTabState extends ConsumerState { double? _libraryGridScaleStartExtent; int _libraryPageLimit = _libraryPageSize; bool _libraryPageLoadScheduled = false; + final Map<_QueueLibraryCountsRequest, QueueLibraryCounts> + _queueLibraryCountsCache = {}; + final Map<_QueueLibraryPageRequest, _QueueLibraryPageData> + _queueLibraryPageDataCache = {}; double _effectiveTextScale() { final textScale = MediaQuery.textScalerOf(context).scale(1.0); @@ -249,6 +253,55 @@ class _QueueTabState extends ConsumerState { }); } + QueueLibraryCounts _resolveQueueLibraryCounts( + AsyncValue value, + _QueueLibraryCountsRequest request, + ) { + return value.maybeWhen( + data: (counts) { + _queueLibraryCountsCache[request] = counts; + _trimQueueLibraryCaches(); + return counts; + }, + orElse: () => + _queueLibraryCountsCache[request] ?? + const QueueLibraryCounts( + allTrackCount: 0, + albumCount: 0, + singleTrackCount: 0, + ), + ); + } + + _QueueLibraryPageData _resolveQueueLibraryPageData( + AsyncValue<_QueueLibraryPageData>? value, + _QueueLibraryPageRequest request, + ) { + if (value == null) { + return _queueLibraryPageDataCache[request] ?? + const _QueueLibraryPageData(); + } + return value.maybeWhen( + data: (data) { + _queueLibraryPageDataCache[request] = data; + _trimQueueLibraryCaches(); + return data; + }, + orElse: () => + _queueLibraryPageDataCache[request] ?? const _QueueLibraryPageData(), + ); + } + + void _trimQueueLibraryCaches() { + const maxEntries = 24; + while (_queueLibraryCountsCache.length > maxEntries) { + _queueLibraryCountsCache.remove(_queueLibraryCountsCache.keys.first); + } + while (_queueLibraryPageDataCache.length > maxEntries) { + _queueLibraryPageDataCache.remove(_queueLibraryPageDataCache.keys.first); + } + } + bool _handleLibraryScrollNotification({ required ScrollNotification notification, required String filterMode, @@ -2455,14 +2508,7 @@ class _QueueTabState extends ConsumerState { localLibraryEnabled: localLibraryEnabled, ); final countsValue = ref.watch(_queueLibraryCountsProvider(countsRequest)); - final queueCounts = countsValue.maybeWhen( - data: (counts) => counts, - orElse: () => const QueueLibraryCounts( - allTrackCount: 0, - albumCount: 0, - singleTrackCount: 0, - ), - ); + final queueCounts = _resolveQueueLibraryCounts(countsValue, countsRequest); _QueueLibraryPageRequest pageRequest(String filterMode) => _QueueLibraryPageRequest( @@ -2477,17 +2523,19 @@ class _QueueTabState extends ConsumerState { localLibraryEnabled: localLibraryEnabled, ); + final pageRequests = { + for (final mode in _filterModes) mode: pageRequest(mode), + }; final pageValues = >{ - for (final mode in _filterModes) - mode: ref.watch(_queueLibraryPageProvider(pageRequest(mode))), + for (final entry in pageRequests.entries) + entry.key: ref.watch(_queueLibraryPageProvider(entry.value)), }; _QueueLibraryPageData pageData(String filterMode) => - pageValues[filterMode]?.maybeWhen( - data: (data) => data, - orElse: () => const _QueueLibraryPageData(), - ) ?? - const _QueueLibraryPageData(); + _resolveQueueLibraryPageData( + pageValues[filterMode], + pageRequests[filterMode]!, + ); _FilterContentData getFilterData(String filterMode) { return pageData(filterMode).toFilterContentData(