upload-state-indicators.md 7.7 KB

Per-Track Upload State Indicators — Shaped Brief

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

Problem

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.

Goal

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.

Non-goals (v1)

  • Batch upload queue UI
  • Per-row inline progress bar (just state icon — orange=uploading, not a ring)
  • Upload history table (separate from Track model)
  • Full lifecycle transitions (local→cloud after file deletion)
  • Server-side cloudTrackId reconciliation after beets reorganizes

Acceptance Criteria

  • Local tracks that have been uploaded show green ↑ (arrow.up.circle.fill) after title
  • Track currently uploading shows orange ↑ (arrow.up.circle.fill)
  • Failed upload shows red ⚠ (exclamationmark.circle.fill), clears on retry
  • Upload state persists across app restarts (SwiftData)
  • Cloud tracks retain existing download state icons unchanged
  • NEW: downloading cloud tracks show hollow ↓ (arrow.down.circle) in accent color
  • NEW: download error shows red ⚠ (exclamationmark.circle.fill)
  • The v1 uploadingFileName hack in TrackRow is removed — replaced by track.uploadState
  • "Upload to Cloud" context menu sets track.uploadState through the lifecycle
  • After successful upload, cloudTrackId from server response is stored on the Track (Option A linking — enables future dedup in cloud browser)
  • Group-level: right-click a group header → "Upload All to Cloud" uploads all eligible local tracks in that group sequentially
  • Group-level: group header shows aggregate upload state indicator after track count:
    • Any uploading → orange ↑ with count (e.g., "2/18")
    • Any errors (none uploading) → red ⚠ with count
    • All local tracks uploaded → green ↑ (no count)
    • Some uploaded, some not → hollow ↑ (arrow.up.circle) secondary color with count
    • No tracks have upload state → no indicator

Appetite

Small-Medium — existing patterns, no new architecture:

  • 1 new enum (UploadState, mirrors DownloadState)
  • 1 new property on Track model (uploadStateRaw)
  • Modify TrackRow (unified icon resolver replaces current if/else chain)
  • Modify UploadService (accept Track, set uploadState + cloudTrackId)
  • Modify GroupHeaderView (aggregate state icon + "Upload All" context menu)
  • Sequential group upload (reuse UploadService per-track, queued)
  • ~5-6 files changed, existing patterns only

Architecture

Data Model

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 }
}

Visual Design — Single Icon Slot, Priority-Ordered

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).

Identity Linking (Option A, minimal)

  • After successful upload, server returns track_id in JSON response
  • UploadService stores this as track.cloudTrackId
  • Cloud browser already deduplicates by cloudTrackId (existing code in bulkInsertCloudTracks and addToPlaylist)
  • If user deletes local Track from SwiftData, link is lost — acceptable for v1

UploadService Changes

  • startUpload(fileURL:apiClient:)startUpload(track:apiClient:) (accept Track)
  • Set track.uploadState = .uploading on start
  • Set track.uploadState = .uploaded + track.cloudTrackId on success
  • Set track.uploadState = .error on failure
  • Remove the uploadingFileName concept entirely

Group-Level Upload (Designer: @designer)

Aggregate 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):

  • Shows when: ≥1 track in group is local, has file on disk, upload state is .none or .error, Chad Music configured
  • Uploads eligible tracks sequentially (reuse UploadService, one at a time)
  • "Retry Failed Uploads" as separate item when any track has .error state

GroupHeaderView changes:

  • Add tracks: [Track] parameter (all tracks in the group)
  • Compute GroupUploadSummary from tracks (priority logic + count)
  • Add .contextMenu with upload/retry actions

Sequential group upload:

  • UploadService gets uploadBatch(tracks:apiClient:) method
  • Iterates tracks, uploads one at a time, skips already-uploaded
  • Each track's uploadState updates live → both track rows and group header react via SwiftUI observation

Lifecycle — Upload → Delete → Re-download

1. 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)

Dependencies & Blockers

  • Cloud Upload v1 server endpoint must return track_id in its response (requires server-side change: include the new track's ID in the JSON)
  • SwiftData lightweight migration must handle the new property

Risks

  1. SwiftData migration: adding uploadStateRaw with a default value should trigger automatic lightweight migration. Test with existing data.
  2. Beets track ID staleness: after beets moves/renames the file, the cloudTrackId stored on the local Track becomes invalid. Optimistic for now — errors will surface when user tries to interact via the stale ID.

Deferred to v2

  • Server-side cloudTrackId reconciliation (SyncWatcher validates links)
  • Upload history table (separate from Track model, survives Track deletion)
  • Full local→cloud Track identity transition on file deletion
  • Retry/dismiss from error state via icon tap