feat/music-streaming
Laurent 1 month ago
parent 9b6bd8929e
commit 7f31945b84
  1. 6
      Music.xcodeproj/project.pbxproj
  2. 2
      Music/Assets.xcassets/AppIcon.appiconset/Contents.json
  3. BIN
      Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png
  4. BIN
      Music/Assets.xcassets/AppIcon.appiconset/mumu_icon.png
  5. 23
      Music/ContentView.swift
  6. 21
      Music/Info.plist
  7. 11
      Music/Models/TrackContextMenuConfig.swift
  8. 11
      Music/ViewModels/LibraryViewModel.swift
  9. 109
      Music/Views/PlaylistBarView.swift
  10. 26
      Music/Views/TrackTableView.swift
  11. 343
      docs/superpowers/plans/2026-05-31-track-drag-to-playlist.md
  12. 85
      docs/superpowers/specs/2026-05-31-track-drag-to-playlist-design.md

@ -447,7 +447,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP; DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@ -458,6 +458,7 @@
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Music/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Mumu; INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
@ -493,7 +494,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 27;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP; DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@ -504,6 +505,7 @@
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Music/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Mumu; INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";

@ -46,7 +46,7 @@
"size" : "512x512" "size" : "512x512"
}, },
{ {
"filename" : "icon_mu.png", "filename" : "mumu_icon.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "512x512" "size" : "512x512"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

@ -19,7 +19,6 @@ struct ContentView: View {
@Binding var showSmartPlaylistBuilder: Bool @Binding var showSmartPlaylistBuilder: Bool
var networkStatus: NetworkStatus? var networkStatus: NetworkStatus?
@State private var infoRequest: TrackInfoRequest? @State private var infoRequest: TrackInfoRequest?
@State private var saveWarning: String?
@State private var showRenameAlert = false @State private var showRenameAlert = false
@State private var showEditQueryAlert = false @State private var showEditQueryAlert = false
@State private var smartPlaylistBuilderEditing: SmartPlaylist? @State private var smartPlaylistBuilderEditing: SmartPlaylist?
@ -258,6 +257,10 @@ struct ContentView: View {
}, },
onEditConditions: { smart in onEditConditions: { smart in
smartPlaylistBuilderEditing = smart smartPlaylistBuilderEditing = smart
},
onDropTrack: { trackId, targetPlaylist in
guard let track = library.tracks.first(where: { $0.id == trackId }) else { return }
try? playlist.addTrack(track, to: targetPlaylist)
} }
) )
@ -397,28 +400,12 @@ struct ContentView: View {
let targets = req.tracks let targets = req.tracks
infoRequest = nil infoRequest = nil
Task { Task {
let warnings = await library.applyTrackEdits(values, editing: edited, to: targets) _ = await library.applyTrackEdits(values, editing: edited, to: targets)
if !warnings.isEmpty {
let failed = warnings.filter { $0.kind == .fileWriteFailed }.count
let dbOnly = warnings.filter { $0.kind == .dbOnlyUnsupported }.count
var msg = "Saved to your library."
if failed > 0 { msg += " Couldn’t write tags to \(failed) file(s)." }
if dbOnly > 0 { msg += " \(dbOnly) file(s) use a format without tag writing." }
saveWarning = msg
}
} }
}, },
onCancel: { infoRequest = nil } onCancel: { infoRequest = nil }
) )
} }
.alert("Edit Saved", isPresented: Binding(
get: { saveWarning != nil },
set: { if !$0 { saveWarning = nil } }
)) {
Button("OK", role: .cancel) { saveWarning = nil }
} message: {
Text(saveWarning ?? "")
}
} }
/// True only when driving a separate remote device (RemotePlaybackProvider is active). /// True only when driving a separate remote device (RemotePlaybackProvider is active).

@ -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>

@ -18,6 +18,7 @@ nonisolated struct TrackContextMenuConfig {
// Opens "Get Info" for the resolved target set (full selection if the // Opens "Get Info" for the resolved target set (full selection if the
// right-clicked row is part of it, else just the clicked row). nil hides it. // right-clicked row is part of it, else just the clicked row). nil hides it.
let onGetInfo: (([Track]) -> Void)? let onGetInfo: (([Track]) -> Void)?
let onDelete: (([Track]) -> Void)?
// Explicit init so that onPlayNext, onAddToQueue and onGetInfo default to nil, // Explicit init so that onPlayNext, onAddToQueue and onGetInfo default to nil,
// allowing existing call sites that omit them to keep compiling unchanged. // allowing existing call sites that omit them to keep compiling unchanged.
@ -31,7 +32,8 @@ nonisolated struct TrackContextMenuConfig {
onPlayNext: ((Track) -> Void)? = nil, onPlayNext: ((Track) -> Void)? = nil,
onAddToQueue: ((Track) -> Void)? = nil, onAddToQueue: ((Track) -> Void)? = nil,
onAddToNewPlaylist: ((Track) -> Void)? = nil, onAddToNewPlaylist: ((Track) -> Void)? = nil,
onGetInfo: (([Track]) -> Void)? = nil onGetInfo: (([Track]) -> Void)? = nil,
onDelete: (([Track]) -> Void)? = nil
) { ) {
self.playlists = playlists self.playlists = playlists
self.lastUsedPlaylistName = lastUsedPlaylistName self.lastUsedPlaylistName = lastUsedPlaylistName
@ -43,6 +45,7 @@ nonisolated struct TrackContextMenuConfig {
self.onAddToQueue = onAddToQueue self.onAddToQueue = onAddToQueue
self.onAddToNewPlaylist = onAddToNewPlaylist self.onAddToNewPlaylist = onAddToNewPlaylist
self.onGetInfo = onGetInfo self.onGetInfo = onGetInfo
self.onDelete = onDelete
} }
} }
@ -101,6 +104,12 @@ nonisolated extension TrackContextMenuConfig {
entries.append(.button(title: "Remove from Playlist") { onRemoveFromPlaylist(track) }) entries.append(.button(title: "Remove from Playlist") { onRemoveFromPlaylist(track) })
} }
if let onDelete {
let targets = selection.isEmpty ? [track] : selection
entries.append(.separator)
entries.append(.button(title: "Delete") { onDelete(targets) })
}
return Self.normalizeSeparators(entries) return Self.normalizeSeparators(entries)
} }

@ -54,6 +54,17 @@ final class LibraryViewModel {
updateQuery() updateQuery()
} }
func deleteTracks(_ tracks: [Track], moveToTrash: Bool) throws {
let urls = Set(tracks.map(\.fileURL))
try db.deleteTracksWithURLs(urls)
if moveToTrash {
for track in tracks {
let url = URL(fileURLWithPath: track.fileURL)
try? FileManager.default.trashItem(at: url, resultingItemURL: nil)
}
}
}
private func updateQuery() { private func updateQuery() {
cancellable?.cancel() cancellable?.cancel()
let search = searchText let search = searchText

@ -1,4 +1,7 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
private let trackIdUTType = UTType(exportedAs: "com.music.trackID")
struct PlaylistBarView: View { struct PlaylistBarView: View {
var playlists: [any PlaylistRepresentable] var playlists: [any PlaylistRepresentable]
@ -12,6 +15,7 @@ struct PlaylistBarView: View {
var onDelete: (any PlaylistRepresentable) -> Void var onDelete: (any PlaylistRepresentable) -> Void
var onEditQuery: (SmartPlaylist) -> Void var onEditQuery: (SmartPlaylist) -> Void
var onEditConditions: (SmartPlaylist) -> Void var onEditConditions: (SmartPlaylist) -> Void
var onDropTrack: ((Int64, Playlist) -> Void)?
var body: some View { var body: some View {
FlowLayout(spacing: 6) { FlowLayout(spacing: 6) {
@ -24,31 +28,28 @@ struct PlaylistBarView: View {
) )
ForEach(playlists, id: \.listIdentity) { item in ForEach(playlists, id: \.listIdentity) { item in
PlaylistButton( let isRegular = item is Playlist
name: item.name, PlaylistChip(
item: item,
isSelected: selectedItem?.listIdentity == item.listIdentity, isSelected: selectedItem?.listIdentity == item.listIdentity,
isSmart: item.isSmartPlaylist, isRemoteMode: isRemoteMode,
action: { acceptsDrop: isRegular,
trackIdUTType: trackIdUTType,
onTap: {
if selectedItem?.listIdentity == item.listIdentity { if selectedItem?.listIdentity == item.listIdentity {
onDeselect() onDeselect()
} else { } else {
onSelect(item) onSelect(item)
} }
} },
onDropTrack: isRegular ? { trackId in
onDropTrack?(trackId, item as! Playlist)
} : nil,
onRename: { onRename(item) },
onDelete: { onDelete(item) },
onEditQuery: (item as? SmartPlaylist).flatMap { smart in smart.conditions == nil ? { onEditQuery(smart) } : nil },
onEditConditions: (item as? SmartPlaylist).flatMap { smart in smart.conditions != nil ? { onEditConditions(smart) } : nil }
) )
.contextMenu {
if !isRemoteMode {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
if smart.conditions != nil {
Button("Edit...") { onEditConditions(smart) }
} else {
Button("Edit Search Query...") { onEditQuery(smart) }
}
}
Button("Delete") { onDelete(item) }
}
}
} }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@ -56,11 +57,63 @@ struct PlaylistBarView: View {
} }
} }
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() }
}
}
}
}
private struct PlaylistButton: View { private struct PlaylistButton: View {
let name: String let name: String
let isSelected: Bool let isSelected: Bool
let isSmart: Bool let isSmart: Bool
var icon: String? = nil var icon: String? = nil
var isDropTarget: Bool = false
let action: () -> Void let action: () -> Void
private var tintColor: Color { private var tintColor: Color {
@ -83,14 +136,30 @@ private struct PlaylistButton: View {
.font(.system(size: 11)) .font(.system(size: 11))
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 5) .padding(.vertical, 5)
.background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1)) .background(
isDropTarget ? tintColor.opacity(0.3) :
isSelected ? tintColor.opacity(0.2) :
Color.secondary.opacity(0.1)
)
.foregroundStyle(isSelected ? tintColor : inactiveColor) .foregroundStyle(isSelected ? tintColor : inactiveColor)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.stroke(isSelected ? tintColor : Color.secondary.opacity(0.3), lineWidth: 1) .stroke(
isDropTarget ? tintColor :
isSelected ? tintColor :
Color.secondary.opacity(0.3),
lineWidth: isDropTarget ? 2 : 1
)
) )
.cornerRadius(4) .cornerRadius(4)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
private extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { transform(self) } else { self }
}
}

@ -5,6 +5,8 @@ private let visibleColumnsKey = "visibleTrackColumns"
private let defaultVisibleColumnIds: Set<String> = ["title", "artist", "album", "genre", "duration"] private let defaultVisibleColumnIds: Set<String> = ["title", "artist", "album", "genre", "duration"]
private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID")
private let columnDefinitions: [(id: String, title: String, width: CGFloat, rightAlign: Bool)] = [ private let columnDefinitions: [(id: String, title: String, width: CGFloat, rightAlign: Bool)] = [
("title", "Title", 300, false), ("title", "Title", 300, false),
("artist", "Artist", 200, false), ("artist", "Artist", 200, false),
@ -130,13 +132,13 @@ struct TrackTableView: NSViewRepresentable {
tableView.sortDescriptors = [expectedDescriptor] tableView.sortDescriptors = [expectedDescriptor]
} }
if context.coordinator.parent.onReorder != nil { let needsReorder = context.coordinator.parent.onReorder != nil
if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) { let wantedTypes: [NSPasteboard.PasteboardType] = needsReorder
tableView.registerForDraggedTypes([.string]) ? [trackIdPasteboardType, .string]
tableView.draggingDestinationFeedbackStyle = .gap : [trackIdPasteboardType]
} if Set(tableView.registeredDraggedTypes) != Set(wantedTypes) {
} else { tableView.registerForDraggedTypes(wantedTypes)
tableView.unregisterDraggedTypes() tableView.draggingDestinationFeedbackStyle = needsReorder ? .gap : .none
} }
if scrollTriggered { if scrollTriggered {
@ -370,8 +372,14 @@ struct TrackTableView: NSViewRepresentable {
// MARK: - Drag and Drop // MARK: - Drag and Drop
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
guard parent.onReorder != nil else { return nil } let item = NSPasteboardItem()
return "\(row)" as NSString if let trackId = tracks[row].id {
item.setString(String(trackId), forType: trackIdPasteboardType)
}
if parent.onReorder != nil {
item.setString(String(row), forType: .string)
}
return item
} }
func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {

@ -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…
Cancel
Save