Approved: 2026-03-19 Designer: @designer provided mockups and icon vocabulary Depends on: Cloud Upload v1 (briefs/cloud-upload-v1.md) — shipped 2026-03-18
Cloud upload v1 tracks state globally (idle/uploading/success/error) but there's no per-track visual indicator. User can't tell which local tracks have been uploaded to the cloud, which are uploading, or which failed.
Per-track upload state indicators on TrackRow, symmetrical with the existing download state pattern. Also fix two existing gaps: downloading and download error states aren't rendered in TrackRow today.
arrow.up.circle.fill) after titlearrow.up.circle.fill)exclamationmark.circle.fill), clears on retryarrow.down.circle) in accent colorexclamationmark.circle.fill)uploadingFileName hack in TrackRow is removed — replaced by track.uploadStatecloudTrackId from server response is stored on the Track
(Option A linking — enables future dedup in cloud browser)arrow.up.circle) secondary color with countSmall-Medium — existing patterns, no new architecture:
enum UploadState: String, Codable {
case none
case uploading
case uploaded
case error
}
// On Track model (mirrors downloadStateRaw pattern):
var uploadStateRaw: String = UploadState.none.rawValue
var uploadState: UploadState {
get { UploadState(rawValue: uploadStateRaw) ?? .none }
set { uploadStateRaw = newValue.rawValue }
}
One icon after the title text. Priority determines which state wins:
| Priority | State | Icon | Color |
|---|---|---|---|
| 1 | Uploading | arrow.up.circle.fill |
.orange |
| 2 | Upload error | exclamationmark.circle.fill |
.red |
| 3 | Downloading | arrow.down.circle (hollow) |
.accentColor |
| 4 | Download error | exclamationmark.circle.fill |
.red |
| 5 | Downloaded | arrow.down.circle.fill |
.green |
| 6 | Uploaded | arrow.up.circle.fill |
.green |
| 7 | Cloud only | cloud.fill |
.accentColor |
| 8 | Local only | none | — |
All icons 11pt, .foregroundStyle only, inline in HStack(spacing: 4).
track_id in JSON responsetrack.cloudTrackIdcloudTrackId (existing code in
bulkInsertCloudTracks and addToPlaylist)startUpload(fileURL:apiClient:) → startUpload(track:apiClient:) (accept Track)track.uploadState = .uploading on starttrack.uploadState = .uploaded + track.cloudTrackId on successtrack.uploadState = .error on failureuploadingFileName concept entirelyAggregate state indicator on GroupHeaderView (after track count):
🎵 Only Built 4 Cuban Linx... (1995) (18) ↑ 12/18
↑ aggregate icon + count
Priority ordering (same hierarchy as track-level):
| Priority | Condition | Icon | Color | Count |
|---|---|---|---|---|
| 1 | Any track uploading | arrow.up.circle.fill |
.orange |
"2/18" |
| 2 | Any error (none uploading) | exclamationmark.circle.fill |
.red |
"15/18" |
| 3 | All local tracks uploaded | arrow.up.circle.fill |
.green |
none |
| 4 | Some uploaded, some not | arrow.up.circle (hollow) |
.secondary |
"12/18" |
| 5 | No upload state on any track | — | — | — |
Count uses 10pt monospaced to match BPM/key/duration pill styling. Icon is 11pt.
"Upload All to Cloud" context menu on group header (right-click):
.none or .error, Chad Music configured.error stateGroupHeaderView changes:
tracks: [Track] parameter (all tracks in the group)GroupUploadSummary from tracks (priority logic + count).contextMenu with upload/retry actionsSequential group upload:
uploadBatch(tracks:apiClient:) methoduploadState updates live → both track rows and group header
react via SwiftUI observation1. Local, no cloud → no icon
2. User uploads → ↑ orange (uploading)
3. Upload completes → ↑ green (uploaded, cloudTrackId set)
4. Beets reorganizes → ↑ green (unchanged, cloudTrackId may be stale)
5. User deletes local → Track gone from SwiftData, link lost
6. Browse cloud → ☁ accent (new ChadTrack, no local Track)
7. Download → ↓ hollow (downloading, new Track created)
8. Downloaded → ↓ green (isCloud=true, localCachePath set)
track_id in its response
(requires server-side change: include the new track's ID in the JSON)uploadStateRaw with a default value should
trigger automatic lightweight migration. Test with existing data.cloudTrackId stored on the local Track becomes invalid. Optimistic for
now — errors will surface when user tries to interact via the stale ID.