11 KiB
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:
private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID")
- Step 2: Update
pasteboardWriterForRowto always write track ID
Replace the current implementation (lines 372-375):
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
guard parent.onReorder != nil else { return nil }
return "\(row)" as NSString
}
With:
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
validateDropandacceptDropto read.stringtype 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):
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):
if context.coordinator.parent.onReorder != nil {
if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) {
tableView.registerForDraggedTypes([.string])
tableView.draggingDestinationFeedbackStyle = .gap
}
} else {
tableView.unregisterDraggedTypes()
}
With:
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
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.onDropto playlist chips) -
Modify:
Music/Views/PlaylistBarView.swift:59-96(addisDropTargetparam toPlaylistButton) -
Step 1: Add
onDropTrackcallback andUniformTypeIdentifiersimport
Add at the top of the file:
import UniformTypeIdentifiers
Add a new property to PlaylistBarView after the existing callbacks (after line 14):
var onDropTrack: ((Int64, Playlist) -> Void)?
Also add a private constant for the UTType (inside the file, outside the struct — near the import):
private let trackIdUTType = UTType("com.music.trackID")!
- Step 2: Add
.onDropmodifier to regular playlist chips
Replace the ForEach block (lines 26-52) with:
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
PlaylistChipwrapper view to manage drop state
Add this view between PlaylistBarView and PlaylistButton (it owns the @State for isTargeted):
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
.ifview 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:
private extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { transform(self) } else { self }
}
}
- Step 5: Add
isDropTargetparameter to PlaylistButton
Update PlaylistButton to accept and use the highlight state. Add the parameter:
var isDropTarget: Bool = false
Update the .background and .overlay in the button body to respond to isDropTarget:
.background(
isDropTarget ? tintColor.opacity(0.3) :
isSelected ? tintColor.opacity(0.2) :
Color.secondary.opacity(0.1)
)
.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
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(addonDropTrackto PlaylistBarView call) -
Step 1: Add
onDropTrackclosure to PlaylistBarView instantiation
In ContentView.swift, add the onDropTrack parameter to the PlaylistBarView(...) call, after the onEditConditions closure (after line 259):
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
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
- Ensure at least one regular playlist exists
- Drag a track from the track table
- Hover over a regular playlist chip — verify it highlights
- Drop on the chip — verify the track appears in the playlist (click the playlist to check)
- Step 3: Test edge cases
- Drag a track over a smart playlist chip — verify no highlight, drop rejected
- Drag a track that's already in a playlist onto that playlist — verify silent no-op
- Drag a track over the Home chip — verify no highlight
- After a drag-to-playlist, try reordering tracks within a playlist — verify still works
- Drop a file from Finder onto the app — verify library scan still works
- Step 4: Fix any issues found, commit