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/specs/2026-05-31-track-drag-to-pl...

3.5 KiB

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).

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:

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