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