|
|
@@ -1,39 +1,171 @@
|
|
|
-## Task: Shape upload state indicators on track rows
|
|
|
+# Per-Track Upload State Indicators — Shaped Brief
|
|
|
|
|
|
-### Context
|
|
|
+> 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 shipped (2026-03-18): single file upload via toolbar + right-click context menu on local tracks. The v1 `UploadService` tracks state globally (idle/uploading/success/error) but there's **no per-track visual indicator** of upload status.
|
|
|
+## 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.
|
|
|
|
|
|
-We already have per-track **download** state indicators on track rows (downloaded/downloading/error icons, managed by `DownloadManager` and `Track.downloadState`). The user wants **symmetrical upload indicators** — so a track row shows whether it's been uploaded to the cloud, is currently uploading, or 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.
|
|
|
|
|
|
-### What needs shaping
|
|
|
+## 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
|
|
|
|
|
|
-1. **Per-track upload state model**: Currently `UploadService` is fire-and-forget — it doesn't associate uploads with Track objects. We need a way to track "this local Track has been uploaded to cloud" or "is currently uploading". Options:
|
|
|
- - Add `uploadState` to the `Track` SwiftData model (mirrors `downloadState`)
|
|
|
- - Keep it transient (in-memory only, lost on restart)
|
|
|
- - Persist upload history separately
|
|
|
+## 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
|
|
|
|
|
|
-2. **Visual indicators on track rows**: The row already shows download state icons. How should upload state appear?
|
|
|
- - Same position as download indicators? Or opposite side?
|
|
|
- - What icons/colors for: uploading (progress), uploaded (checkmark? cloud?), failed (red)?
|
|
|
- - Should "uploaded" persist after beets reorganizes the file (server-side the track ID changes)?
|
|
|
+## 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
|
|
|
|
|
|
-3. **Interaction between upload and download states**: A track could theoretically be both "downloaded from cloud" AND "uploaded to cloud" (if you download a cloud track, it becomes local, then re-upload). How to handle overlapping states?
|
|
|
+## Architecture
|
|
|
|
|
|
-4. **Multi-file queue implications**: If we later add batch upload (#1 from pre-brief), per-track state becomes essential for showing queue progress. Shape this with batch in mind.
|
|
|
+### Data Model
|
|
|
+```swift
|
|
|
+enum UploadState: String, Codable {
|
|
|
+ case none
|
|
|
+ case uploading
|
|
|
+ case uploaded
|
|
|
+ case error
|
|
|
+}
|
|
|
|
|
|
-### Design questions for @designer
|
|
|
-- Where on the track row should upload state appear?
|
|
|
-- Icon vocabulary: what SF Symbols for uploading/uploaded/failed?
|
|
|
-- Does it conflict with the existing download state indicators?
|
|
|
-- Color coding: match download state colors or differentiate?
|
|
|
+// On Track model (mirrors downloadStateRaw pattern):
|
|
|
+var uploadStateRaw: String = UploadState.none.rawValue
|
|
|
+var uploadState: UploadState {
|
|
|
+ get { UploadState(rawValue: uploadStateRaw) ?? .none }
|
|
|
+ set { uploadStateRaw = newValue.rawValue }
|
|
|
+}
|
|
|
+```
|
|
|
|
|
|
-### Existing code to review
|
|
|
-- `Sources/Models/Track.swift` — has `downloadState` enum and property
|
|
|
-- `Sources/Views/TrackRow.swift` — renders download state indicators
|
|
|
-- `Sources/Views/PlaylistView.swift` — context menu with "Upload to Cloud"
|
|
|
-- `Sources/Services/UploadService.swift` — current global state, no per-track tracking
|
|
|
-- `Sources/Services/DownloadManager.swift` — per-track download state pattern to mirror
|
|
|
+### Visual Design — Single Icon Slot, Priority-Ordered
|
|
|
|
|
|
-### Pre-brief reference
|
|
|
-See `briefs/cloud-upload-v2-prebrief.md` for the full list of deferred upload items and suggested grouping.
|
|
|
+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
|