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.
343 lines
11 KiB
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**
|
|
|