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