diff --git a/Music.xcodeproj/project.pbxproj b/Music.xcodeproj/project.pbxproj index 5a26c6f..af8d573 100644 --- a/Music.xcodeproj/project.pbxproj +++ b/Music.xcodeproj/project.pbxproj @@ -447,7 +447,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 27; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; @@ -458,6 +458,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Music/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mumu; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -493,7 +494,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 27; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; @@ -504,6 +505,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Music/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mumu; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; diff --git a/Music/Assets.xcassets/AppIcon.appiconset/Contents.json b/Music/Assets.xcassets/AppIcon.appiconset/Contents.json index e53c300..2660361 100644 --- a/Music/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Music/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -46,7 +46,7 @@ "size" : "512x512" }, { - "filename" : "icon_mu.png", + "filename" : "mumu_icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png b/Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png deleted file mode 100644 index 96b39e8..0000000 Binary files a/Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png and /dev/null differ diff --git a/Music/Assets.xcassets/AppIcon.appiconset/mumu_icon.png b/Music/Assets.xcassets/AppIcon.appiconset/mumu_icon.png new file mode 100644 index 0000000..c5cdd20 Binary files /dev/null and b/Music/Assets.xcassets/AppIcon.appiconset/mumu_icon.png differ diff --git a/Music/ContentView.swift b/Music/ContentView.swift index 2c4d233..2dfe29e 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -19,7 +19,6 @@ struct ContentView: View { @Binding var showSmartPlaylistBuilder: Bool var networkStatus: NetworkStatus? @State private var infoRequest: TrackInfoRequest? - @State private var saveWarning: String? @State private var showRenameAlert = false @State private var showEditQueryAlert = false @State private var smartPlaylistBuilderEditing: SmartPlaylist? @@ -258,6 +257,10 @@ struct ContentView: View { }, onEditConditions: { smart in smartPlaylistBuilderEditing = smart + }, + onDropTrack: { trackId, targetPlaylist in + guard let track = library.tracks.first(where: { $0.id == trackId }) else { return } + try? playlist.addTrack(track, to: targetPlaylist) } ) @@ -397,28 +400,12 @@ struct ContentView: View { let targets = req.tracks infoRequest = nil Task { - let warnings = await library.applyTrackEdits(values, editing: edited, to: targets) - if !warnings.isEmpty { - let failed = warnings.filter { $0.kind == .fileWriteFailed }.count - let dbOnly = warnings.filter { $0.kind == .dbOnlyUnsupported }.count - var msg = "Saved to your library." - if failed > 0 { msg += " Couldn’t write tags to \(failed) file(s)." } - if dbOnly > 0 { msg += " \(dbOnly) file(s) use a format without tag writing." } - saveWarning = msg - } + _ = await library.applyTrackEdits(values, editing: edited, to: targets) } }, onCancel: { infoRequest = nil } ) } - .alert("Edit Saved", isPresented: Binding( - get: { saveWarning != nil }, - set: { if !$0 { saveWarning = nil } } - )) { - Button("OK", role: .cancel) { saveWarning = nil } - } message: { - Text(saveWarning ?? "") - } } /// True only when driving a separate remote device (RemotePlaybackProvider is active). diff --git a/Music/Info.plist b/Music/Info.plist new file mode 100644 index 0000000..07be41f --- /dev/null +++ b/Music/Info.plist @@ -0,0 +1,21 @@ + + + + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + Music Track ID + UTTypeIdentifier + com.music.trackID + UTTypeTagSpecification + + + + + diff --git a/Music/Models/TrackContextMenuConfig.swift b/Music/Models/TrackContextMenuConfig.swift index e685f81..c0248be 100644 --- a/Music/Models/TrackContextMenuConfig.swift +++ b/Music/Models/TrackContextMenuConfig.swift @@ -18,6 +18,7 @@ nonisolated struct TrackContextMenuConfig { // Opens "Get Info" for the resolved target set (full selection if the // right-clicked row is part of it, else just the clicked row). nil hides it. let onGetInfo: (([Track]) -> Void)? + let onDelete: (([Track]) -> Void)? // Explicit init so that onPlayNext, onAddToQueue and onGetInfo default to nil, // allowing existing call sites that omit them to keep compiling unchanged. @@ -31,7 +32,8 @@ nonisolated struct TrackContextMenuConfig { onPlayNext: ((Track) -> Void)? = nil, onAddToQueue: ((Track) -> Void)? = nil, onAddToNewPlaylist: ((Track) -> Void)? = nil, - onGetInfo: (([Track]) -> Void)? = nil + onGetInfo: (([Track]) -> Void)? = nil, + onDelete: (([Track]) -> Void)? = nil ) { self.playlists = playlists self.lastUsedPlaylistName = lastUsedPlaylistName @@ -43,6 +45,7 @@ nonisolated struct TrackContextMenuConfig { self.onAddToQueue = onAddToQueue self.onAddToNewPlaylist = onAddToNewPlaylist self.onGetInfo = onGetInfo + self.onDelete = onDelete } } @@ -101,6 +104,12 @@ nonisolated extension TrackContextMenuConfig { entries.append(.button(title: "Remove from Playlist") { onRemoveFromPlaylist(track) }) } + if let onDelete { + let targets = selection.isEmpty ? [track] : selection + entries.append(.separator) + entries.append(.button(title: "Delete") { onDelete(targets) }) + } + return Self.normalizeSeparators(entries) } diff --git a/Music/ViewModels/LibraryViewModel.swift b/Music/ViewModels/LibraryViewModel.swift index f6be2db..7f58c95 100644 --- a/Music/ViewModels/LibraryViewModel.swift +++ b/Music/ViewModels/LibraryViewModel.swift @@ -54,6 +54,17 @@ final class LibraryViewModel { updateQuery() } + func deleteTracks(_ tracks: [Track], moveToTrash: Bool) throws { + let urls = Set(tracks.map(\.fileURL)) + try db.deleteTracksWithURLs(urls) + if moveToTrash { + for track in tracks { + let url = URL(fileURLWithPath: track.fileURL) + try? FileManager.default.trashItem(at: url, resultingItemURL: nil) + } + } + } + private func updateQuery() { cancellable?.cancel() let search = searchText diff --git a/Music/Views/PlaylistBarView.swift b/Music/Views/PlaylistBarView.swift index c384f73..9a6648e 100644 --- a/Music/Views/PlaylistBarView.swift +++ b/Music/Views/PlaylistBarView.swift @@ -1,4 +1,7 @@ import SwiftUI +import UniformTypeIdentifiers + +private let trackIdUTType = UTType(exportedAs: "com.music.trackID") struct PlaylistBarView: View { var playlists: [any PlaylistRepresentable] @@ -12,6 +15,7 @@ struct PlaylistBarView: View { var onDelete: (any PlaylistRepresentable) -> Void var onEditQuery: (SmartPlaylist) -> Void var onEditConditions: (SmartPlaylist) -> Void + var onDropTrack: ((Int64, Playlist) -> Void)? var body: some View { FlowLayout(spacing: 6) { @@ -24,31 +28,28 @@ struct PlaylistBarView: View { ) ForEach(playlists, id: \.listIdentity) { item in - PlaylistButton( - name: item.name, + let isRegular = item is Playlist + PlaylistChip( + item: item, isSelected: selectedItem?.listIdentity == item.listIdentity, - isSmart: item.isSmartPlaylist, - action: { + isRemoteMode: isRemoteMode, + acceptsDrop: isRegular, + trackIdUTType: trackIdUTType, + onTap: { if selectedItem?.listIdentity == item.listIdentity { onDeselect() } else { onSelect(item) } - } + }, + onDropTrack: isRegular ? { trackId in + onDropTrack?(trackId, item as! Playlist) + } : nil, + onRename: { onRename(item) }, + onDelete: { onDelete(item) }, + onEditQuery: (item as? SmartPlaylist).flatMap { smart in smart.conditions == nil ? { onEditQuery(smart) } : nil }, + onEditConditions: (item as? SmartPlaylist).flatMap { smart in smart.conditions != nil ? { onEditConditions(smart) } : nil } ) - .contextMenu { - if !isRemoteMode { - Button("Rename...") { onRename(item) } - if let smart = item as? SmartPlaylist { - if smart.conditions != nil { - Button("Edit...") { onEditConditions(smart) } - } else { - Button("Edit Search Query...") { onEditQuery(smart) } - } - } - Button("Delete") { onDelete(item) } - } - } } } .padding(.horizontal, 12) @@ -56,11 +57,63 @@ struct PlaylistBarView: View { } } +private struct PlaylistChip: View { + let item: any PlaylistRepresentable + let isSelected: Bool + let isRemoteMode: Bool + let acceptsDrop: Bool + let trackIdUTType: UTType + let onTap: () -> Void + var onDropTrack: ((Int64) -> Void)? + let onRename: () -> Void + let onDelete: () -> Void + var onEditQuery: (() -> Void)? + var onEditConditions: (() -> Void)? + + @State private var isDropTargeted = false + + var body: some View { + PlaylistButton( + name: item.name, + isSelected: isSelected, + isSmart: item.isSmartPlaylist, + isDropTarget: isDropTargeted, + action: onTap + ) + .if(acceptsDrop) { view in + view.onDrop(of: [trackIdUTType], isTargeted: $isDropTargeted) { providers in + guard let provider = providers.first else { return false } + provider.loadItem(forTypeIdentifier: trackIdUTType.identifier) { data, _ in + guard let data = data as? Data, + let str = String(data: data, encoding: .utf8), + let trackId = Int64(str) else { return } + DispatchQueue.main.async { + onDropTrack?(trackId) + } + } + return true + } + } + .contextMenu { + if !isRemoteMode { + Button("Rename...") { onRename() } + if let onEditConditions { + Button("Edit...") { onEditConditions() } + } else if let onEditQuery { + Button("Edit Search Query...") { onEditQuery() } + } + Button("Delete") { onDelete() } + } + } + } +} + private struct PlaylistButton: View { let name: String let isSelected: Bool let isSmart: Bool var icon: String? = nil + var isDropTarget: Bool = false let action: () -> Void private var tintColor: Color { @@ -83,14 +136,30 @@ private struct PlaylistButton: View { .font(.system(size: 11)) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .background( + isDropTarget ? tintColor.opacity(0.3) : + isSelected ? tintColor.opacity(0.2) : + Color.secondary.opacity(0.1) + ) .foregroundStyle(isSelected ? tintColor : inactiveColor) .overlay( RoundedRectangle(cornerRadius: 4) - .stroke(isSelected ? tintColor : Color.secondary.opacity(0.3), lineWidth: 1) + .stroke( + isDropTarget ? tintColor : + isSelected ? tintColor : + Color.secondary.opacity(0.3), + lineWidth: isDropTarget ? 2 : 1 + ) ) .cornerRadius(4) } .buttonStyle(.plain) } } + +private extension View { + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { transform(self) } else { self } + } +} diff --git a/Music/Views/TrackTableView.swift b/Music/Views/TrackTableView.swift index bfc7386..ce0aa4a 100644 --- a/Music/Views/TrackTableView.swift +++ b/Music/Views/TrackTableView.swift @@ -5,6 +5,8 @@ private let visibleColumnsKey = "visibleTrackColumns" private let defaultVisibleColumnIds: Set = ["title", "artist", "album", "genre", "duration"] +private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID") + private let columnDefinitions: [(id: String, title: String, width: CGFloat, rightAlign: Bool)] = [ ("title", "Title", 300, false), ("artist", "Artist", 200, false), @@ -130,13 +132,13 @@ struct TrackTableView: NSViewRepresentable { tableView.sortDescriptors = [expectedDescriptor] } - if context.coordinator.parent.onReorder != nil { - if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) { - tableView.registerForDraggedTypes([.string]) - tableView.draggingDestinationFeedbackStyle = .gap - } - } else { - tableView.unregisterDraggedTypes() + let needsReorder = context.coordinator.parent.onReorder != nil + let wantedTypes: [NSPasteboard.PasteboardType] = needsReorder + ? [trackIdPasteboardType, .string] + : [trackIdPasteboardType] + if Set(tableView.registeredDraggedTypes) != Set(wantedTypes) { + tableView.registerForDraggedTypes(wantedTypes) + tableView.draggingDestinationFeedbackStyle = needsReorder ? .gap : .none } if scrollTriggered { @@ -370,8 +372,14 @@ struct TrackTableView: NSViewRepresentable { // MARK: - Drag and Drop func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { - guard parent.onReorder != nil else { return nil } - return "\(row)" as NSString + let item = NSPasteboardItem() + if let trackId = tracks[row].id { + item.setString(String(trackId), forType: trackIdPasteboardType) + } + if parent.onReorder != nil { + item.setString(String(row), forType: .string) + } + return item } func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { diff --git a/docs/superpowers/plans/2026-05-31-track-drag-to-playlist.md b/docs/superpowers/plans/2026-05-31-track-drag-to-playlist.md new file mode 100644 index 0000000..ec24b4c --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-track-drag-to-playlist.md @@ -0,0 +1,343 @@ +# Track Drag-to-Playlist Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Drag a single track from the track table onto a regular playlist chip to add it. + +**Architecture:** The NSTableView drag source writes the track's `Int64` ID to the pasteboard under a custom type. Each regular playlist chip in `PlaylistBarView` becomes a SwiftUI drop target that reads the ID and calls the existing `PlaylistViewModel.addTrack` method. A highlight shows when a valid drag hovers over a chip. + +**Tech Stack:** SwiftUI, AppKit (NSTableView/NSPasteboard), GRDB + +--- + +### Task 1: Add custom pasteboard type and update drag source + +**Files:** +- Modify: `Music/Views/TrackTableView.swift:4` (add constant) +- Modify: `Music/Views/TrackTableView.swift:133-140` (register drag types) +- Modify: `Music/Views/TrackTableView.swift:372-375` (pasteboardWriterForRow) + +- [ ] **Step 1: Add the custom pasteboard type constant** + +At the top of `TrackTableView.swift`, after line 6 (`private let defaultVisibleColumnIds`), add: + +```swift +private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID") +``` + +- [ ] **Step 2: Update `pasteboardWriterForRow` to always write track ID** + +Replace the current implementation (lines 372-375): + +```swift +func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + guard parent.onReorder != nil else { return nil } + return "\(row)" as NSString +} +``` + +With: + +```swift +func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + let item = NSPasteboardItem() + if let trackId = parent.tracks[row].id { + item.setString(String(trackId), forType: trackIdPasteboardType) + } + if parent.onReorder != nil { + item.setString(String(row), forType: .string) + } + return item +} +``` + +- [ ] **Step 3: Update `validateDrop` and `acceptDrop` to read `.string` type explicitly** + +The current `acceptDrop` reads from the first pasteboard item's generic string. Now that we write two types, it must read `.string` specifically. + +Replace `acceptDrop` (lines 382-393): + +```swift +func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool { + guard let onReorder = parent.onReorder else { return false } + guard let item = info.draggingPasteboard.pasteboardItems?.first, + let rowString = item.string(forType: .string), + let sourceRow = Int(rowString) else { return false } + + let destination = sourceRow < row ? row - 1 : row + guard sourceRow != destination else { return false } + + onReorder(sourceRow, destination) + return true +} +``` + +- [ ] **Step 4: Update drag type registration in `updateNSView`** + +Replace the drag registration block (lines 133-140): + +```swift +if context.coordinator.parent.onReorder != nil { + if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) { + tableView.registerForDraggedTypes([.string]) + tableView.draggingDestinationFeedbackStyle = .gap + } +} else { + tableView.unregisterDraggedTypes() +} +``` + +With: + +```swift +let needsReorder = context.coordinator.parent.onReorder != nil +let wantedTypes: [NSPasteboard.PasteboardType] = needsReorder + ? [trackIdPasteboardType, .string] + : [trackIdPasteboardType] +if Set(tableView.registeredDraggedTypes) != Set(wantedTypes) { + tableView.registerForDraggedTypes(wantedTypes) + tableView.draggingDestinationFeedbackStyle = needsReorder ? .gap : .none +} +``` + +- [ ] **Step 5: Build and verify** + +Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 6: Commit** + +```bash +git add Music/Views/TrackTableView.swift +git commit -m "feat: write track ID to pasteboard on drag start" +``` + +--- + +### Task 2: Add drop target to PlaylistBarView + +**Files:** +- Modify: `Music/Views/PlaylistBarView.swift:3-14` (add callback prop) +- Modify: `Music/Views/PlaylistBarView.swift:26-51` (add `.onDrop` to playlist chips) +- Modify: `Music/Views/PlaylistBarView.swift:59-96` (add `isDropTarget` param to `PlaylistButton`) + +- [ ] **Step 1: Add `onDropTrack` callback and `UniformTypeIdentifiers` import** + +Add at the top of the file: + +```swift +import UniformTypeIdentifiers +``` + +Add a new property to `PlaylistBarView` after the existing callbacks (after line 14): + +```swift +var onDropTrack: ((Int64, Playlist) -> Void)? +``` + +Also add a private constant for the UTType (inside the file, outside the struct — near the import): + +```swift +private let trackIdUTType = UTType("com.music.trackID")! +``` + +- [ ] **Step 2: Add `.onDrop` modifier to regular playlist chips** + +Replace the `ForEach` block (lines 26-52) with: + +```swift +ForEach(playlists, id: \.listIdentity) { item in + let isRegular = item is Playlist + PlaylistChip( + item: item, + isSelected: selectedItem?.listIdentity == item.listIdentity, + isRemoteMode: isRemoteMode, + acceptsDrop: isRegular, + trackIdUTType: trackIdUTType, + onTap: { + if selectedItem?.listIdentity == item.listIdentity { + onDeselect() + } else { + onSelect(item) + } + }, + onDropTrack: isRegular ? { trackId in + onDropTrack?(trackId, item as! Playlist) + } : nil, + onRename: { onRename(item) }, + onDelete: { onDelete(item) }, + onEditQuery: (item as? SmartPlaylist).map { smart in { onEditQuery(smart) } }, + onEditConditions: (item as? SmartPlaylist).map { smart in { onEditConditions(smart) } } + ) +} +``` + +- [ ] **Step 3: Create a `PlaylistChip` wrapper view to manage drop state** + +Add this view between `PlaylistBarView` and `PlaylistButton` (it owns the `@State` for `isTargeted`): + +```swift +private struct PlaylistChip: View { + let item: any PlaylistRepresentable + let isSelected: Bool + let isRemoteMode: Bool + let acceptsDrop: Bool + let trackIdUTType: UTType + let onTap: () -> Void + var onDropTrack: ((Int64) -> Void)? + let onRename: () -> Void + let onDelete: () -> Void + var onEditQuery: (() -> Void)? + var onEditConditions: (() -> Void)? + + @State private var isDropTargeted = false + + var body: some View { + PlaylistButton( + name: item.name, + isSelected: isSelected, + isSmart: item.isSmartPlaylist, + isDropTarget: isDropTargeted, + action: onTap + ) + .if(acceptsDrop) { view in + view.onDrop(of: [trackIdUTType], isTargeted: $isDropTargeted) { providers in + guard let provider = providers.first else { return false } + provider.loadItem(forTypeIdentifier: trackIdUTType.identifier) { data, _ in + guard let data = data as? Data, + let str = String(data: data, encoding: .utf8), + let trackId = Int64(str) else { return } + DispatchQueue.main.async { + onDropTrack?(trackId) + } + } + return true + } + } + .contextMenu { + if !isRemoteMode { + Button("Rename...") { onRename() } + if let onEditConditions { + Button("Edit...") { onEditConditions() } + } else if let onEditQuery { + Button("Edit Search Query...") { onEditQuery() } + } + Button("Delete") { onDelete() } + } + } + } +} +``` + +- [ ] **Step 4: Add the `.if` view extension if it doesn't already exist** + +Check if `View+if` already exists in the project. If not, add it at the bottom of `PlaylistBarView.swift`: + +```swift +private extension View { + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { transform(self) } else { self } + } +} +``` + +- [ ] **Step 5: Add `isDropTarget` parameter to PlaylistButton** + +Update `PlaylistButton` to accept and use the highlight state. Add the parameter: + +```swift +var isDropTarget: Bool = false +``` + +Update the `.background` and `.overlay` in the button body to respond to `isDropTarget`: + +```swift +.background( + isDropTarget ? tintColor.opacity(0.3) : + isSelected ? tintColor.opacity(0.2) : + Color.secondary.opacity(0.1) +) +``` + +```swift +.overlay( + RoundedRectangle(cornerRadius: 4) + .stroke( + isDropTarget ? tintColor : + isSelected ? tintColor : + Color.secondary.opacity(0.3), + lineWidth: isDropTarget ? 2 : 1 + ) +) +``` + +- [ ] **Step 6: Build and verify** + +Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 7: Commit** + +```bash +git add Music/Views/PlaylistBarView.swift +git commit -m "feat: add drop target on playlist chips for track drag" +``` + +--- + +### Task 3: Wire up in ContentView + +**Files:** +- Modify: `Music/ContentView.swift:211-261` (add `onDropTrack` to PlaylistBarView call) + +- [ ] **Step 1: Add `onDropTrack` closure to PlaylistBarView instantiation** + +In `ContentView.swift`, add the `onDropTrack` parameter to the `PlaylistBarView(...)` call, after the `onEditConditions` closure (after line 259): + +```swift +onDropTrack: { trackId, targetPlaylist in + guard let track = library.tracks.first(where: { $0.id == trackId }) else { return } + try? playlist.addTrack(track, to: targetPlaylist) +} +``` + +- [ ] **Step 2: Build and verify** + +Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add Music/ContentView.swift +git commit -m "feat: wire track drag-to-playlist in ContentView" +``` + +--- + +### Task 4: Manual test and fix any issues + +- [ ] **Step 1: Launch the app** + +Run: `cd /Users/laurentmorvillier/code/Music && open -a Xcode Music.xcodeproj` and run from Xcode (Cmd+R), or build and run via command line. + +- [ ] **Step 2: Test the happy path** + +1. Ensure at least one regular playlist exists +2. Drag a track from the track table +3. Hover over a regular playlist chip — verify it highlights +4. Drop on the chip — verify the track appears in the playlist (click the playlist to check) + +- [ ] **Step 3: Test edge cases** + +1. Drag a track over a **smart** playlist chip — verify no highlight, drop rejected +2. Drag a track that's **already** in a playlist onto that playlist — verify silent no-op +3. Drag a track over the **Home** chip — verify no highlight +4. After a drag-to-playlist, try **reordering** tracks within a playlist — verify still works +5. **Drop a file from Finder** onto the app — verify library scan still works + +- [ ] **Step 4: Fix any issues found, commit** diff --git a/docs/superpowers/specs/2026-05-31-track-drag-to-playlist-design.md b/docs/superpowers/specs/2026-05-31-track-drag-to-playlist-design.md new file mode 100644 index 0000000..559436d --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-track-drag-to-playlist-design.md @@ -0,0 +1,85 @@ +# Track Drag-to-Playlist Design + +## Goal + +Drag a single track from the track table onto a playlist chip in the bottom bar to add that track to the playlist. + +## Scope + +- Single track only (no multi-select drag) +- Regular playlists only (smart playlists are read-only) +- Duplicate adds silently ignored (DB `UNIQUE(playlistId, trackId)` constraint) + +## Implementation + +### 1. Custom Pasteboard Type + +Define a custom `NSPasteboard.PasteboardType` for track IDs to avoid collisions with the existing `.string` type used for row-reorder and the `.fileURL` type used for Finder file drops. + +Location: top of `TrackTableView.swift` (alongside existing private constants). + +```swift +private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID") +``` + +### 2. Drag Source — TrackTableView Coordinator + +**File:** `TrackTableView.swift` + +**Change `pasteboardWriterForRow`:** Currently only writes when `onReorder != nil`. Change to always write the track ID under the custom type. When `onReorder` is also set, additionally write the row index under `.string` (preserving existing reorder behavior). + +``` +func pasteboardWriterForRow(row) -> NSPasteboardWriting? + let item = NSPasteboardItem() + // Always write track ID for cross-view drag + if let trackId = parent.tracks[row].id { + item.setString(String(trackId), forType: trackIdPasteboardType) + } + // Also write row index if reorder is enabled + if parent.onReorder != nil { + item.setString(String(row), forType: .string) + } + return item +``` + +**Register for drag types:** Add `trackIdPasteboardType` to `registerForDraggedTypes` alongside `.string`. + +**`validateDrop` / `acceptDrop`:** Update to read from `.string` type specifically (not just first pasteboard item string), so they continue to work for reorder and ignore the track-ID type. + +### 3. Drop Target — PlaylistBarView + +**File:** `PlaylistBarView.swift` + +**New callback prop:** +```swift +var onDropTrack: ((Int64, Playlist) -> Void)? +``` + +**Drop modifier on each regular playlist chip:** Add `.onDrop(of:isTargeted:perform:)` to each `PlaylistButton` for regular playlists only. The `isTargeted` binding drives a visual highlight (accent-colored border/background). + +Since SwiftUI's `.onDrop` works with UTTypes, we register the custom type as a UTType and read the track ID from the `NSItemProvider`. + +**Visual feedback:** When `isTargeted` is true, show a highlighted border/background on the chip (e.g. `tintColor.opacity(0.3)` background + `tintColor` border at 2pt). + +**PlaylistButton changes:** Add an `isDropTarget: Bool` parameter to `PlaylistButton` that controls the highlight styling. Default `false`. + +### 4. Wiring — ContentView + +**File:** `ContentView.swift` + +Pass the `onDropTrack` closure to `PlaylistBarView`. The closure calls `PlaylistViewModel.addTrack(_:to:)`, wrapping in try/catch to silently handle duplicates. + +## Files Changed + +| File | Change | +|------|--------| +| `TrackTableView.swift` | Custom pasteboard type constant; `pasteboardWriterForRow` writes track ID always + row index when reordering; register custom drag type; update `validateDrop`/`acceptDrop` to read `.string` explicitly | +| `PlaylistBarView.swift` | `onDropTrack` callback; `.onDrop` modifier on regular playlist chips; `isDropTarget` highlight state on `PlaylistButton` | +| `ContentView.swift` | Wire `onDropTrack` closure through to `PlaylistBarView` | + +## Behaviors Preserved + +- Right-click "Add to Playlist" context menu unchanged +- Drag reorder within a playlist unchanged +- File drops from Finder unchanged +- Smart playlists don't accept drops