Przeglądaj źródła

feat: upload state indicators and playlist upload UX

- Track model upload state support
- UploadService batch upload improvements
- PlaylistView upload button and progress UI
- TrackRow upload state indicator
- CloudBrowserView cleanup
- Upload state indicators brief and design system updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
aldiss 3 miesięcy temu
rodzic
commit
80550cca69

+ 1 - 0
.github/agents/builder.agent.md

@@ -1,5 +1,6 @@
 ---
 ---
 description: "Builder — combined architect/engineer agent. Coordinates multiple AI models to investigate, design, and build through structured deliberation gates. Domain-agnostic — works on any problem domain by loading domain packs as skills. Use when: building features, fixing bugs, code review, architecture, any task needing multi-model review. Receives shaped briefs from @pm."
 description: "Builder — combined architect/engineer agent. Coordinates multiple AI models to investigate, design, and build through structured deliberation gates. Domain-agnostic — works on any problem domain by loading domain packs as skills. Use when: building features, fixing bugs, code review, architecture, any task needing multi-model review. Receives shaped briefs from @pm."
+model: claude-opus-4.6
 ---
 ---
 
 
 # Builder — Multi-Model Deliberation Engine
 # Builder — Multi-Model Deliberation Engine

+ 4 - 0
MixBoard.xcodeproj/project.pbxproj

@@ -77,6 +77,7 @@
 		EC0DD99AFFFDA7D25407E991 /* ArtworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB242ECEFF0FFF4427B42BC0 /* ArtworkService.swift */; };
 		EC0DD99AFFFDA7D25407E991 /* ArtworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB242ECEFF0FFF4427B42BC0 /* ArtworkService.swift */; };
 		ED3B403C28CF291E3483823E /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5615C432F50F99E53303D0 /* DownloadManager.swift */; };
 		ED3B403C28CF291E3483823E /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5615C432F50F99E53303D0 /* DownloadManager.swift */; };
 		EE13D90C3C2ACF1348391C69 /* KeyDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0457B660537DC8CAD1B6120 /* KeyDetector.swift */; };
 		EE13D90C3C2ACF1348391C69 /* KeyDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0457B660537DC8CAD1B6120 /* KeyDetector.swift */; };
+		F0FF4D62FCE23A447DDE628F /* PlaylistUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C2D7260E0D82FD7D0BDA28 /* PlaylistUploadButton.swift */; };
 		F2E4BE62D73171D8E7D63006 /* CueSheetExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8A672BB52C77A8E83F3FFF /* CueSheetExporter.swift */; };
 		F2E4BE62D73171D8E7D63006 /* CueSheetExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8A672BB52C77A8E83F3FFF /* CueSheetExporter.swift */; };
 		F7058DDE85BB601CBB7C9BD9 /* GlobalSearchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80C9BACD548FF942E79C82F /* GlobalSearchSheet.swift */; };
 		F7058DDE85BB601CBB7C9BD9 /* GlobalSearchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80C9BACD548FF942E79C82F /* GlobalSearchSheet.swift */; };
 /* End PBXBuildFile section */
 /* End PBXBuildFile section */
@@ -103,6 +104,7 @@
 		10686F358CF00951BE31A568 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
 		10686F358CF00951BE31A568 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
 		1108B34F3CB9DD25F292F8ED /* stb_vorbis_wrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stb_vorbis_wrapper.h; sourceTree = "<group>"; };
 		1108B34F3CB9DD25F292F8ED /* stb_vorbis_wrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stb_vorbis_wrapper.h; sourceTree = "<group>"; };
 		12C20156249966253CB0BC01 /* PlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistView.swift; sourceTree = "<group>"; };
 		12C20156249966253CB0BC01 /* PlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistView.swift; sourceTree = "<group>"; };
+		14C2D7260E0D82FD7D0BDA28 /* PlaylistUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistUploadButton.swift; sourceTree = "<group>"; };
 		1A61463B001623599676BEB7 /* GroupTemplateEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTemplateEditorSheet.swift; sourceTree = "<group>"; };
 		1A61463B001623599676BEB7 /* GroupTemplateEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTemplateEditorSheet.swift; sourceTree = "<group>"; };
 		1BAF527C3BCDBD3D04BFA787 /* UploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = "<group>"; };
 		1BAF527C3BCDBD3D04BFA787 /* UploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = "<group>"; };
 		1BB9760CCC20660A8525CE39 /* ChadMusicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusicTests.swift; sourceTree = "<group>"; };
 		1BB9760CCC20660A8525CE39 /* ChadMusicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusicTests.swift; sourceTree = "<group>"; };
@@ -321,6 +323,7 @@
 				C91BFDC4EF6125CE0A92C365 /* NowPlayingView.swift */,
 				C91BFDC4EF6125CE0A92C365 /* NowPlayingView.swift */,
 				7DB6892183CB93C7DD0FD546 /* PlayerView.swift */,
 				7DB6892183CB93C7DD0FD546 /* PlayerView.swift */,
 				46FC27EACD460EB3137577FA /* PlaylistDownloadButton.swift */,
 				46FC27EACD460EB3137577FA /* PlaylistDownloadButton.swift */,
+				14C2D7260E0D82FD7D0BDA28 /* PlaylistUploadButton.swift */,
 				12C20156249966253CB0BC01 /* PlaylistView.swift */,
 				12C20156249966253CB0BC01 /* PlaylistView.swift */,
 				6EB4D92D99DAB7F01E39A0C5 /* QueueView.swift */,
 				6EB4D92D99DAB7F01E39A0C5 /* QueueView.swift */,
 				01D496B90B255DE7A6A04105 /* SettingsView.swift */,
 				01D496B90B255DE7A6A04105 /* SettingsView.swift */,
@@ -488,6 +491,7 @@
 				C5176BA733BF12E3469B0EAC /* Playlist.swift in Sources */,
 				C5176BA733BF12E3469B0EAC /* Playlist.swift in Sources */,
 				BA52D57A925349BFDA049016 /* PlaylistDownloadButton.swift in Sources */,
 				BA52D57A925349BFDA049016 /* PlaylistDownloadButton.swift in Sources */,
 				E60123D4FFD92FBD9B3B4E69 /* PlaylistFolder.swift in Sources */,
 				E60123D4FFD92FBD9B3B4E69 /* PlaylistFolder.swift in Sources */,
+				F0FF4D62FCE23A447DDE628F /* PlaylistUploadButton.swift in Sources */,
 				1528E4838F567A508BE4A11D /* PlaylistView.swift in Sources */,
 				1528E4838F567A508BE4A11D /* PlaylistView.swift in Sources */,
 				A7A5B8BB3004AB1F33924352 /* PlaylistViewConfig.swift in Sources */,
 				A7A5B8BB3004AB1F33924352 /* PlaylistViewConfig.swift in Sources */,
 				691A0746845CBD34C766E634 /* PlaylistViewModel.swift in Sources */,
 				691A0746845CBD34C766E634 /* PlaylistViewModel.swift in Sources */,

+ 17 - 0
Sources/Models/Track.swift

@@ -9,6 +9,14 @@ enum DownloadState: String, Codable {
     case error
     case error
 }
 }
 
 
+/// Upload state for local tracks.
+enum UploadState: String, Codable {
+    case none
+    case uploading
+    case uploaded
+    case error
+}
+
 /// Represents a single audio track in the library.
 /// Represents a single audio track in the library.
 @Model
 @Model
 final class Track {
 final class Track {
@@ -51,6 +59,9 @@ final class Track {
     /// Download state for cloud tracks — stored as raw string value of DownloadState enum.
     /// Download state for cloud tracks — stored as raw string value of DownloadState enum.
     var downloadStateRaw: String = DownloadState.none.rawValue
     var downloadStateRaw: String = DownloadState.none.rawValue
 
 
+    /// Upload state for local tracks — stored as raw string value of UploadState enum.
+    var uploadStateRaw: String = UploadState.none.rawValue
+
     /// Cached waveform samples (downsampled min/max pairs), stored as Data for efficiency.
     /// Cached waveform samples (downsampled min/max pairs), stored as Data for efficiency.
     var waveformData: Data?
     var waveformData: Data?
 
 
@@ -75,6 +86,12 @@ final class Track {
         set { downloadStateRaw = newValue.rawValue }
         set { downloadStateRaw = newValue.rawValue }
     }
     }
 
 
+    /// Upload state as typed enum (read/write through uploadStateRaw).
+    var uploadState: UploadState {
+        get { UploadState(rawValue: uploadStateRaw) ?? .none }
+        set { uploadStateRaw = newValue.rawValue }
+    }
+
     /// True if this track has a playable local file — either a local library track or a downloaded cloud track.
     /// True if this track has a playable local file — either a local library track or a downloaded cloud track.
     /// Performs stale file recovery: if localCachePath is set but file is missing, resets download state.
     /// Performs stale file recovery: if localCachePath is set but file is missing, resets download state.
     var hasPlayableLocalFile: Bool {
     var hasPlayableLocalFile: Bool {

+ 39 - 4
Sources/Services/UploadService.swift

@@ -44,11 +44,34 @@ final class UploadService: NSObject, @unchecked Sendable {
 
 
     // MARK: - Public API
     // MARK: - Public API
 
 
-    /// Start uploading a file. Cancels any in-progress upload first.
+    /// Start uploading a track's local file. Cancels any in-progress upload first.
+    func startUpload(track: Track, apiClient: ChadMusicAPIClient) {
+        cancel()
+        track.uploadState = .uploading
+        uploadTask = Task {
+            await performUpload(track: track, fileURL: URL(fileURLWithPath: track.filePath), apiClient: apiClient)
+        }
+    }
+
+    /// Start uploading a file from a URL (file picker / drag-and-drop, no Track object).
     func startUpload(fileURL: URL, apiClient: ChadMusicAPIClient) {
     func startUpload(fileURL: URL, apiClient: ChadMusicAPIClient) {
         cancel()
         cancel()
         uploadTask = Task {
         uploadTask = Task {
-            await performUpload(fileURL: fileURL, apiClient: apiClient)
+            await performUpload(track: nil, fileURL: fileURL, apiClient: apiClient)
+        }
+    }
+
+    /// Upload a batch of tracks sequentially.
+    func uploadBatch(tracks: [Track], apiClient: ChadMusicAPIClient) {
+        cancel()
+        uploadTask = Task {
+            for track in tracks {
+                guard !Task.isCancelled else { break }
+                guard track.uploadState != .uploaded else { continue }
+                guard track.hasLocalFile, FileManager.default.fileExists(atPath: track.filePath) else { continue }
+                track.uploadState = .uploading
+                await performUpload(track: track, fileURL: URL(fileURLWithPath: track.filePath), apiClient: apiClient)
+            }
         }
         }
     }
     }
 
 
@@ -68,9 +91,10 @@ final class UploadService: NSObject, @unchecked Sendable {
 
 
     // MARK: - Upload Implementation
     // MARK: - Upload Implementation
 
 
-    private func performUpload(fileURL: URL, apiClient: ChadMusicAPIClient) async {
+    private func performUpload(track: Track?, fileURL: URL, apiClient: ChadMusicAPIClient) async {
         guard apiClient.isConfigured else {
         guard apiClient.isConfigured else {
             state = .error("Chad Music not configured")
             state = .error("Chad Music not configured")
+            track?.uploadState = .error
             return
             return
         }
         }
 
 
@@ -80,6 +104,7 @@ final class UploadService: NSObject, @unchecked Sendable {
 
 
         guard let contentType = Self.contentType(for: fileURL) else {
         guard let contentType = Self.contentType(for: fileURL) else {
             state = .error("Unsupported format: .\(fileURL.pathExtension)")
             state = .error("Unsupported format: .\(fileURL.pathExtension)")
+            track?.uploadState = .error
             return
             return
         }
         }
 
 
@@ -88,6 +113,7 @@ final class UploadService: NSObject, @unchecked Sendable {
         let normalized = base.hasSuffix("/") ? base : base + "/"
         let normalized = base.hasSuffix("/") ? base : base + "/"
         guard let url = URL(string: normalized + "api/upload") else {
         guard let url = URL(string: normalized + "api/upload") else {
             state = .error("Invalid server URL")
             state = .error("Invalid server URL")
+            track?.uploadState = .error
             return
             return
         }
         }
 
 
@@ -122,6 +148,7 @@ final class UploadService: NSObject, @unchecked Sendable {
 
 
             guard let http = response as? HTTPURLResponse else {
             guard let http = response as? HTTPURLResponse else {
                 state = .error("Invalid server response")
                 state = .error("Invalid server response")
+                track?.uploadState = .error
                 return
                 return
             }
             }
 
 
@@ -132,25 +159,33 @@ final class UploadService: NSObject, @unchecked Sendable {
                     tracksAdded: result?.tracksAdded ?? 0,
                     tracksAdded: result?.tracksAdded ?? 0,
                     albumsUpdated: result?.albumsUpdated ?? 0
                     albumsUpdated: result?.albumsUpdated ?? 0
                 )
                 )
+                track?.uploadState = .uploaded
             case 401:
             case 401:
                 state = .error("Unauthorized — check your API key")
                 state = .error("Unauthorized — check your API key")
+                track?.uploadState = .error
             case 413:
             case 413:
                 state = .error("File too large (max 200 MB)")
                 state = .error("File too large (max 200 MB)")
+                track?.uploadState = .error
             default:
             default:
                 let result = try? JSONDecoder().decode(UploadResult.self, from: data)
                 let result = try? JSONDecoder().decode(UploadResult.self, from: data)
                 state = .error(
                 state = .error(
                     result?.message ?? "Server error (HTTP \(http.statusCode))"
                     result?.message ?? "Server error (HTTP \(http.statusCode))"
                 )
                 )
+                track?.uploadState = .error
             }
             }
         } catch is CancellationError {
         } catch is CancellationError {
             // User cancelled — state already reset by cancel()
             // User cancelled — state already reset by cancel()
+            if track?.uploadState == .uploading { track?.uploadState = .none }
         } catch {
         } catch {
             if !Task.isCancelled {
             if !Task.isCancelled {
                 if (error as NSError).code == NSURLErrorCancelled {
                 if (error as NSError).code == NSURLErrorCancelled {
-                    // URLSession cancellation
+                    if track?.uploadState == .uploading { track?.uploadState = .none }
                 } else {
                 } else {
                     state = .error(error.localizedDescription)
                     state = .error(error.localizedDescription)
+                    track?.uploadState = .error
                 }
                 }
+            } else {
+                if track?.uploadState == .uploading { track?.uploadState = .none }
             }
             }
         }
         }
     }
     }

+ 2 - 16
Sources/Views/CloudBrowserView.swift

@@ -17,12 +17,6 @@ struct CloudBrowserView: View {
     @State private var uploadService = UploadService.shared
     @State private var uploadService = UploadService.shared
     @State private var navStack: [CloudNavDestination] = []
     @State private var navStack: [CloudNavDestination] = []
 
 
-    /// Filename currently being uploaded, if any.
-    private var uploadingFileName: String? {
-        if case .uploading(let fileName) = uploadService.state { return fileName }
-        return nil
-    }
-
     var body: some View {
     var body: some View {
         if !apiClient.isConfigured {
         if !apiClient.isConfigured {
             CloudNotConfiguredView()
             CloudNotConfiguredView()
@@ -44,7 +38,7 @@ struct CloudBrowserView: View {
                     case .category(let cat):
                     case .category(let cat):
                         CategoryDetailView(apiClient: apiClient, category: cat, navStack: $navStack)
                         CategoryDetailView(apiClient: apiClient, category: cat, navStack: $navStack)
                     case .album(let album):
                     case .album(let album):
-                        AlbumDetailView(apiClient: apiClient, album: album, navStack: $navStack, uploadingFileName: uploadingFileName)
+                        AlbumDetailView(apiClient: apiClient, album: album, navStack: $navStack)
                     case .filter(let filter):
                     case .filter(let filter):
                         FilteredAlbumsView(apiClient: apiClient, filter: filter, navStack: $navStack)
                         FilteredAlbumsView(apiClient: apiClient, filter: filter, navStack: $navStack)
                     }
                     }
@@ -602,7 +596,6 @@ private struct AlbumDetailView: View {
     let apiClient: ChadMusicAPIClient
     let apiClient: ChadMusicAPIClient
     let album: ChadAlbum
     let album: ChadAlbum
     @Binding var navStack: [CloudNavDestination]
     @Binding var navStack: [CloudNavDestination]
-    var uploadingFileName: String? = nil
 
 
     @Environment(PlayerViewModel.self) private var playerVM
     @Environment(PlayerViewModel.self) private var playerVM
     @Environment(\.modelContext) private var modelContext
     @Environment(\.modelContext) private var modelContext
@@ -717,7 +710,6 @@ private struct AlbumDetailView: View {
                                 playerVM.currentTrack?.cloudTrackId == track.id
                                 playerVM.currentTrack?.cloudTrackId == track.id
                             ),
                             ),
                             persistedTrack: persistedTracks[track.id],
                             persistedTrack: persistedTracks[track.id],
-                            uploadingFileName: uploadingFileName,
                             onDownload: {
                             onDownload: {
                                 let persisted = ensurePersistedTrack(for: track)
                                 let persisted = ensurePersistedTrack(for: track)
                                 downloadManager.download(track: persisted, apiClient: apiClient)
                                 downloadManager.download(track: persisted, apiClient: apiClient)
@@ -898,7 +890,6 @@ private struct CloudTrackRow: View {
     let track: ChadTrack
     let track: ChadTrack
     let isPlaying: Bool
     let isPlaying: Bool
     var persistedTrack: Track?
     var persistedTrack: Track?
-    var uploadingFileName: String? = nil
     var onDownload: (() -> Void)? = nil
     var onDownload: (() -> Void)? = nil
 
 
     var body: some View {
     var body: some View {
@@ -936,12 +927,7 @@ private struct CloudTrackRow: View {
             Spacer()
             Spacer()
 
 
             // Upload / download indicator for cloud tracks
             // Upload / download indicator for cloud tracks
-            if let uploadingFileName, URL(string: track.url)?.lastPathComponent == uploadingFileName {
-                Image(systemName: "arrow.up.circle.fill")
-                    .font(.system(size: 14))
-                    .foregroundStyle(.orange)
-                    .frame(width: 20, height: 20)
-            } else if let persistedTrack {
+            if let persistedTrack {
                 DownloadIndicator(track: persistedTrack)
                 DownloadIndicator(track: persistedTrack)
             } else {
             } else {
                 Button {
                 Button {

+ 57 - 0
Sources/Views/PlaylistUploadButton.swift

@@ -0,0 +1,57 @@
+import SwiftUI
+
+/// Upload button for playlist headers — visible only when playlist has local tracks and Chad Music is configured.
+/// Shows uploaded/total count (e.g., "↑ 2/9").
+struct PlaylistUploadButton: View {
+    let playlist: Playlist
+
+    @State private var uploadService = UploadService.shared
+
+    private var localTracks: [Track] {
+        playlist.sortedEntries.compactMap(\.track).filter { !$0.isCloud && !$0.filePath.isEmpty }
+    }
+
+    private var uploadedCount: Int {
+        localTracks.filter { $0.uploadState == .uploaded }.count
+    }
+
+    private var uploadingCount: Int {
+        localTracks.filter { $0.uploadState == .uploading }.count
+    }
+
+    var body: some View {
+        let local = localTracks
+        if !local.isEmpty && ChadMusicAPIClient.shared.isConfigured {
+            Button {
+                handleTap(local)
+            } label: {
+                Label(buttonLabel(local), systemImage: "arrow.up.circle")
+            }
+            .help(helpText(local))
+        }
+    }
+
+    private func buttonLabel(_ local: [Track]) -> String {
+        "↑ \(uploadedCount)/\(local.count)"
+    }
+
+    private func helpText(_ local: [Track]) -> String {
+        let total = local.count
+        if uploadedCount == total {
+            return "All local tracks uploaded to cloud"
+        }
+        if uploadingCount > 0 {
+            return "Uploading... tap to cancel"
+        }
+        return "Upload \(total - uploadedCount) local tracks to cloud"
+    }
+
+    private func handleTap(_ local: [Track]) {
+        if uploadingCount > 0 {
+            uploadService.cancel()
+        } else if uploadedCount < local.count {
+            let eligible = local.filter { $0.uploadState != .uploaded }
+            uploadService.uploadBatch(tracks: eligible, apiClient: ChadMusicAPIClient.shared)
+        }
+    }
+}

+ 132 - 3
Sources/Views/PlaylistView.swift

@@ -239,6 +239,8 @@ private struct PlaylistHeader: View {
 
 
                 PlaylistDownloadButton(playlist: playlist)
                 PlaylistDownloadButton(playlist: playlist)
 
 
+                PlaylistUploadButton(playlist: playlist)
+
                 Button { onExport() } label: {
                 Button { onExport() } label: {
                     Label("Export", systemImage: "square.and.arrow.up")
                     Label("Export", systemImage: "square.and.arrow.up")
                 }
                 }
@@ -361,11 +363,13 @@ private struct PlaylistEntryList: View {
                         Section {
                         Section {
                             groupContent(group.entries)
                             groupContent(group.entries)
                         } header: {
                         } header: {
+                            let tracks = group.entries.compactMap(\.entry.track)
                             GroupHeaderView(
                             GroupHeaderView(
                                 title: group.key,
                                 title: group.key,
                                 trackCount: group.entries.count,
                                 trackCount: group.entries.count,
                                 firstTrack: group.entries.first?.entry.track,
                                 firstTrack: group.entries.first?.entry.track,
-                                showArtwork: viewConfig.showArtwork
+                                showArtwork: viewConfig.showArtwork,
+                                tracks: tracks
                             )
                             )
                         }
                         }
                     } else {
                     } else {
@@ -541,12 +545,11 @@ private struct PlaylistEntryList: View {
                     // Upload local track to cloud
                     // Upload local track to cloud
                     if !track.isCloud,
                     if !track.isCloud,
                        !track.filePath.isEmpty,
                        !track.filePath.isEmpty,
-                       FileManager.default.fileExists(atPath: track.filePath),
                        ChadMusicAPIClient.shared.isConfigured {
                        ChadMusicAPIClient.shared.isConfigured {
                         Divider()
                         Divider()
                         Button {
                         Button {
                             UploadService.shared.startUpload(
                             UploadService.shared.startUpload(
-                                fileURL: URL(fileURLWithPath: track.filePath),
+                                track: track,
                                 apiClient: ChadMusicAPIClient.shared
                                 apiClient: ChadMusicAPIClient.shared
                             )
                             )
                         } label: {
                         } label: {
@@ -673,6 +676,8 @@ private struct GroupHeaderView: View {
     let trackCount: Int
     let trackCount: Int
     let firstTrack: Track?
     let firstTrack: Track?
     let showArtwork: Bool
     let showArtwork: Bool
+    var tracks: [Track] = []
+    @State private var isHovering = false
     @EnvironmentObject private var theme: AppTheme
     @EnvironmentObject private var theme: AppTheme
 
 
     var body: some View {
     var body: some View {
@@ -688,8 +693,132 @@ private struct GroupHeaderView: View {
             Text("(\(trackCount))")
             Text("(\(trackCount))")
                 .font(.system(size: theme.smallFontSize + 1))
                 .font(.system(size: theme.smallFontSize + 1))
                 .foregroundStyle(theme.tertiaryText)
                 .foregroundStyle(theme.tertiaryText)
+
+            uploadStatusBadge
+
+            Spacer()
+
+            groupCloudActionButton
         }
         }
+        .frame(maxWidth: .infinity, alignment: .leading)
         .padding(.vertical, 2)
         .padding(.vertical, 2)
+        .contentShape(Rectangle())
+        .onHover { isHovering = $0 }
+        .contextMenu {
+            let eligible = tracks.filter {
+                !$0.isCloud && !$0.filePath.isEmpty
+                && $0.uploadState != .uploaded
+                && ChadMusicAPIClient.shared.isConfigured
+            }
+            if !eligible.isEmpty {
+                Button {
+                    UploadService.shared.uploadBatch(tracks: eligible, apiClient: ChadMusicAPIClient.shared)
+                } label: {
+                    Label("Upload All to Cloud", systemImage: "arrow.up.to.cloud")
+                }
+            }
+
+            let failed = tracks.filter { $0.uploadState == .error }
+            if !failed.isEmpty {
+                Button {
+                    UploadService.shared.uploadBatch(tracks: failed, apiClient: ChadMusicAPIClient.shared)
+                } label: {
+                    Label("Retry Failed Uploads", systemImage: "arrow.clockwise")
+                }
+            }
+
+            if eligible.isEmpty && failed.isEmpty {
+                Text("All tracks uploaded")
+                    .foregroundStyle(.secondary)
+            }
+        }
+    }
+
+    // MARK: - Status badge (left side, informational)
+
+    @ViewBuilder
+    private var uploadStatusBadge: some View {
+        let localTracks = tracks.filter { !$0.isCloud && !$0.filePath.isEmpty }
+        let uploading = localTracks.filter { $0.uploadState == .uploading }.count
+        let uploaded = localTracks.filter { $0.uploadState == .uploaded }.count
+        let errors = localTracks.filter { $0.uploadState == .error }.count
+        let total = localTracks.count
+
+        if uploading > 0 {
+            HStack(spacing: 2) {
+                Image(systemName: "arrow.up.circle.fill")
+                    .font(.system(size: 11))
+                    .foregroundStyle(.orange)
+                Text("\(uploaded + uploading)/\(total)")
+                    .font(.system(size: 10, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+            .help("Uploading \(uploading) track\(uploading == 1 ? "" : "s") to cloud")
+        } else if errors > 0 {
+            HStack(spacing: 2) {
+                Image(systemName: "exclamationmark.circle.fill")
+                    .font(.system(size: 11))
+                    .foregroundStyle(.red)
+                Text("\(uploaded)/\(total)")
+                    .font(.system(size: 10, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+            .help("\(errors) upload\(errors == 1 ? "" : "s") failed")
+        } else if uploaded > 0 && uploaded == total {
+            Image(systemName: "checkmark.circle.fill")
+                .font(.system(size: 11))
+                .foregroundStyle(.green)
+                .help("All tracks uploaded to cloud")
+        } else if uploaded > 0 {
+            HStack(spacing: 2) {
+                Image(systemName: "arrow.up.circle")
+                    .font(.system(size: 11))
+                    .foregroundStyle(.secondary)
+                Text("\(uploaded)/\(total)")
+                    .font(.system(size: 10, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+            .help("\(uploaded) of \(total) tracks uploaded to cloud")
+        }
+    }
+
+    // MARK: - Action button (right edge, prominent)
+
+    @ViewBuilder
+    private var groupCloudActionButton: some View {
+        let localTracks = tracks.filter { !$0.isCloud && !$0.filePath.isEmpty }
+        let uploading = localTracks.filter { $0.uploadState == .uploading }.count
+        let uploaded = localTracks.filter { $0.uploadState == .uploaded }.count
+        let total = localTracks.count
+
+        if ChadMusicAPIClient.shared.isConfigured && total > 0 && uploaded < total {
+            if uploading > 0 {
+                Button {
+                    UploadService.shared.cancel()
+                } label: {
+                    Image(systemName: "stop.circle.fill")
+                        .font(.system(size: 16))
+                        .foregroundStyle(.orange)
+                        .frame(width: 28, height: 28)
+                        .contentShape(Rectangle())
+                }
+                .buttonStyle(.plain)
+                .help("Cancel upload")
+            } else {
+                Button {
+                    let eligible = localTracks.filter { $0.uploadState != .uploaded }
+                    UploadService.shared.uploadBatch(tracks: eligible, apiClient: ChadMusicAPIClient.shared)
+                } label: {
+                    Image(systemName: "arrow.up.circle")
+                        .font(.system(size: 16))
+                        .foregroundStyle(isHovering ? Color.accentColor : .secondary)
+                        .frame(width: 28, height: 28)
+                        .contentShape(Rectangle())
+                }
+                .buttonStyle(.plain)
+                .help("Upload \(total - uploaded) tracks to cloud")
+            }
+        }
     }
     }
 }
 }
 
 

+ 35 - 16
Sources/Views/TrackRow.swift

@@ -3,7 +3,6 @@ import SwiftUI
 /// Compact track row for track lists.
 /// Compact track row for track lists.
 struct TrackRow: View {
 struct TrackRow: View {
     let track: Track
     let track: Track
-    var uploadingFileName: String? = nil
 
 
     var body: some View {
     var body: some View {
         HStack(spacing: 8) {
         HStack(spacing: 8) {
@@ -23,21 +22,7 @@ struct TrackRow: View {
                     Text(track.title)
                     Text(track.title)
                         .lineLimit(1)
                         .lineLimit(1)
                         .font(.body)
                         .font(.body)
-                    if let uploadingFileName, track.fileURL.lastPathComponent == uploadingFileName {
-                        Image(systemName: "arrow.up.circle.fill")
-                            .font(.system(size: 11))
-                            .foregroundStyle(.orange)
-                    } else if track.isCloud {
-                        if track.downloadState == .downloaded || track.localCachePath != nil {
-                            Image(systemName: "arrow.down.circle.fill")
-                                .font(.system(size: 11))
-                                .foregroundStyle(.green)
-                        } else {
-                            Image(systemName: "cloud.fill")
-                                .font(.system(size: 11))
-                                .foregroundStyle(Color.accentColor.opacity(0.85))
-                        }
-                    }
+                    trackStateIcon
                 }
                 }
 
 
                 if !track.artist.isEmpty {
                 if !track.artist.isEmpty {
@@ -50,6 +35,40 @@ struct TrackRow: View {
         }
         }
     }
     }
 
 
+    /// Priority-ordered state icon for upload/download/cloud status.
+    @ViewBuilder
+    private var trackStateIcon: some View {
+        if track.uploadState == .uploading {
+            Image(systemName: "arrow.up.circle.fill")
+                .font(.system(size: 11))
+                .foregroundStyle(.orange)
+        } else if track.uploadState == .error {
+            Image(systemName: "exclamationmark.circle.fill")
+                .font(.system(size: 11))
+                .foregroundStyle(.red)
+        } else if track.isCloud && track.downloadState == .downloading {
+            Image(systemName: "arrow.down.circle")
+                .font(.system(size: 11))
+                .foregroundStyle(Color.accentColor)
+        } else if track.isCloud && track.downloadState == .error {
+            Image(systemName: "exclamationmark.circle.fill")
+                .font(.system(size: 11))
+                .foregroundStyle(.red)
+        } else if track.isCloud && (track.downloadState == .downloaded || track.localCachePath != nil) {
+            Image(systemName: "arrow.down.circle.fill")
+                .font(.system(size: 11))
+                .foregroundStyle(.green)
+        } else if track.uploadState == .uploaded {
+            Image(systemName: "arrow.up.circle.fill")
+                .font(.system(size: 11))
+                .foregroundStyle(.green)
+        } else if track.isCloud {
+            Image(systemName: "cloud.fill")
+                .font(.system(size: 11))
+                .foregroundStyle(Color.accentColor.opacity(0.85))
+        }
+    }
+
     private var trackColor: Color {
     private var trackColor: Color {
         if let hex = track.color {
         if let hex = track.color {
             return Color(hex: hex) ?? .accentColor
             return Color(hex: hex) ?? .accentColor

+ 160 - 28
briefs/upload-state-indicators.md

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

+ 32 - 0
design-system.md

@@ -142,6 +142,38 @@ Three-region layout with slide-out panel:
 - **Reason**: Sidebar button was spatially disconnected from the panel (left→right). Having the toggle in the toolbar right next to where the panel appears is more intuitive. Also fixes the SwiftUI List deselection bug (clicking non-tagged rows inside `List(selection:)` clears `selectedPlaylist`).
 - **Reason**: Sidebar button was spatially disconnected from the panel (left→right). Having the toggle in the toolbar right next to where the panel appears is more intuitive. Also fixes the SwiftUI List deselection bug (clicking non-tagged rows inside `List(selection:)` clears `selectedPlaylist`).
 - **Learned pattern**: If a UI element's only purpose is toggling a panel, put the toggle where the panel is — not in a navigation list.
 - **Learned pattern**: If a UI element's only purpose is toggling a panel, put the toggle where the panel is — not in a navigation list.
 
 
+### 2026-03-19 — Upload/Download Action Buttons (Apple Music-inspired)
+- **Options**: (A) Make existing status icons clickable at all levels, (B) Add dedicated action buttons at group header + playlist toolbar, keep track rows status-only, (C) Full hover-action rows with revealed buttons on every track
+- **Choice**: B — Prominent Header Buttons
+- **Reason**: Apple Music's core insight is that batch actions at the container level (album, playlist) are the primary interaction. Per-track is secondary and suits macOS right-click conventions. Track rows at 28pt with 11pt icons are too tight for reliable hover targets. Direction B gives the biggest discoverability win (actions visible without right-click) with the least disruption.
+- **Scope**:
+  - `PlaylistUploadButton` — mirrors existing `PlaylistDownloadButton`, placed after it in toolbar
+  - Group header cloud action button — `arrow.up.to.cloud` / `arrow.down.to.line` / `stop.circle`, placed after upload summary indicator
+  - Track row status icons remain read-only (no change)
+- **Icon vocabulary**: filled = status/completed, outlined/cloud = actionable buttons
+- **Color vocabulary**: `.secondary` at rest, `theme.accentColor` on hover, `.orange` in progress, `.green` completed, `.red` error (all existing tokens)
+- **Learned pattern**: Batch-level buttons > per-item buttons for discoverability in dense lists. Status and action are separate concerns — don't overload one icon with both.
+
+## Components
+
+### PlaylistUploadButton
+- **Purpose**: Playlist-toolbar-level upload action, mirrors `PlaylistDownloadButton`
+- **Placement**: After `PlaylistDownloadButton`, before Export button in `PlaylistHeader` toolbar
+- **Visibility**: Only when playlist has local tracks AND Chad Music is configured
+- **Label**: `↑ N/M` (uploaded count / total local tracks)
+- **Icon**: `arrow.up.circle`
+- **States**: idle → click uploads eligible tracks; uploading → click cancels
+- **Typography**: Matches `PlaylistDownloadButton` (inherits `.controlSize(.small)`)
+
+### Group Header Cloud Action Button
+- **Purpose**: One-click upload/download for group (album) batches
+- **Placement**: After `uploadSummary` in `GroupHeaderView` HStack, before `Spacer()`
+- **Icon mapping**: `arrow.up.to.cloud` (upload), `arrow.down.to.line` (download), `stop.circle` (cancel)
+- **Size**: 11pt icon, 20×20pt hit target frame
+- **Hover**: `.secondary` → `theme.accentColor`, 150ms ease-in-out (instant on reduced-motion)
+- **Priority**: Upload > Download when both directions have actionable tracks
+- **Hidden when**: All tracks are synced (no actionable state)
+
 ## Backlog
 ## Backlog
 
 
 ### Drop cloud tracks between playlist rows (positional insert)
 ### Drop cloud tracks between playlist rows (positional insert)