parent
9b6bd8929e
commit
7f31945b84
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 575 KiB |
@ -0,0 +1,21 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||||
|
<plist version="1.0"> |
||||||
|
<dict> |
||||||
|
<key>UTExportedTypeDeclarations</key> |
||||||
|
<array> |
||||||
|
<dict> |
||||||
|
<key>UTTypeConformsTo</key> |
||||||
|
<array> |
||||||
|
<string>public.data</string> |
||||||
|
</array> |
||||||
|
<key>UTTypeDescription</key> |
||||||
|
<string>Music Track ID</string> |
||||||
|
<key>UTTypeIdentifier</key> |
||||||
|
<string>com.music.trackID</string> |
||||||
|
<key>UTTypeTagSpecification</key> |
||||||
|
<dict/> |
||||||
|
</dict> |
||||||
|
</array> |
||||||
|
</dict> |
||||||
|
</plist> |
||||||
@ -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`<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** |
||||||
@ -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 |
||||||
Loading…
Reference in new issue