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
This commit is contained in:
zarzet
2026-05-06 12:08:53 +07:00
parent 30a7cba02a
commit 83d7106e35
5 changed files with 118 additions and 31 deletions
@@ -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
+9 -4
View File
@@ -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
}
+14 -3
View File
@@ -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) {
+9 -6
View File
@@ -2099,7 +2099,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
continue;
}
if (status == 'preparing') {
updateItemStatus(itemId, DownloadStatus.downloading, progress: 0.0);
continue;
}
if (status == 'downloading') {
updateItemStatus(
itemId,
+63 -15
View File
@@ -150,6 +150,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
});
}
QueueLibraryCounts _resolveQueueLibraryCounts(
AsyncValue<QueueLibraryCounts> 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<QueueTab> {
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<QueueTab> {
localLibraryEnabled: localLibraryEnabled,
);
final pageRequests = <String, _QueueLibraryPageRequest>{
for (final mode in _filterModes) mode: pageRequest(mode),
};
final pageValues = <String, AsyncValue<_QueueLibraryPageData>>{
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(