You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/plans/2026-05-31-track-drag-to-pl...

343 lines
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:
```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`<Content: View>(_ 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**