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/plans/2026-05-30-track-context-me...

525 lines
19 KiB

# Track Context Menu on Bottom Controls — 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:** Right-clicking the now-playing area at bottom-left shows the same Add/Remove playlist context menu as right-clicking a track row in the track table.
**Architecture:** Introduce a `TrackContextMenuConfig` value type that bundles all menu data + callbacks. A new `TrackContextMenuModifier` SwiftUI view modifier applies `.contextMenu` using that config. `TrackTableView` is refactored to accept a single `contextMenuConfig` parameter (replacing six individual playlist params), and `PlayerControlsView` gains the same optional parameter with the modifier applied to `nowPlayingSection`. `ContentView` constructs one config and passes it to both views.
**Tech Stack:** Swift 5.9+, SwiftUI `.contextMenu`, AppKit `NSMenu` (table view keeps existing AppKit path), Swift Testing framework.
---
## File Map
| File | Action |
|------|--------|
| `Music/Models/TrackContextMenuConfig.swift` | **Create** — value type holding playlists + callbacks |
| `Music/Views/TrackContextMenuModifier.swift` | **Create** — SwiftUI ViewModifier + View extension |
| `MusicTests/TrackContextMenuConfigTests.swift` | **Create** — unit tests for config struct |
| `Music/Views/TrackTableView.swift` | **Modify** — replace 6 playlist params with `contextMenuConfig`, update `menuNeedsUpdate` |
| `Music/Views/PlayerControlsView.swift` | **Modify** — add `contextMenuConfig` param, apply modifier to `nowPlayingSection` |
| `Music/ContentView.swift` | **Modify** — construct and pass `TrackContextMenuConfig` to both views |
---
### Task 1: Create `TrackContextMenuConfig`
**Files:**
- Create: `Music/Models/TrackContextMenuConfig.swift`
- Test: `MusicTests/TrackContextMenuConfigTests.swift`
- [ ] **Step 1: Write the failing test**
Create `MusicTests/TrackContextMenuConfigTests.swift`:
```swift
import Testing
@testable import Music
struct TrackContextMenuConfigTests {
// Builds a config with all fields set and verifies:
// - stored playlists, lastUsedPlaylistName, selectedPlaylist match the inputs
// - onAddToPlaylist callback fires with the correct track and playlist
// - onAddToLastPlaylist callback fires with the correct track
// - onRemoveFromPlaylist callback fires with the correct track
// - when optional callbacks are nil, optionally calling them is safe
@Test func storesPropertiesAndFiresCallbacks() {
// 1. Create fixture data
let pl1 = Playlist.fixture(id: 1, name: "Favorites")
let pl2 = Playlist.fixture(id: 2, name: "Chill")
let track = Track.fixture(id: 42, title: "Test")
var addedTrack: Track? = nil
var addedPlaylist: Playlist? = nil
var lastTrack: Track? = nil
var removedTrack: Track? = nil
// 2. Build config with all callbacks
let config = TrackContextMenuConfig(
playlists: [pl1, pl2],
lastUsedPlaylistName: "Favorites",
selectedPlaylist: pl1,
onAddToPlaylist: { t, p in addedTrack = t; addedPlaylist = p },
onAddToLastPlaylist: { t in lastTrack = t },
onRemoveFromPlaylist: { t in removedTrack = t }
)
// 3. Verify stored properties
#expect(config.playlists.count == 2)
#expect(config.playlists[0].name == "Favorites")
#expect(config.lastUsedPlaylistName == "Favorites")
#expect(config.selectedPlaylist == pl1)
// 4. Invoke callbacks and verify they fire correctly
config.onAddToPlaylist(track, pl2)
config.onAddToLastPlaylist?(track)
config.onRemoveFromPlaylist?(track)
#expect(addedTrack?.id == track.id)
#expect(addedPlaylist?.id == pl2.id)
#expect(lastTrack?.id == track.id)
#expect(removedTrack?.id == track.id)
}
@Test func nilOptionalCallbacksAreSafe() {
// Verifies that a config with nil optional callbacks does not crash
// when you call them via optional chaining (the normal usage pattern)
let pl = Playlist.fixture(id: 1, name: "Rock")
let track = Track.fixture()
let config = TrackContextMenuConfig(
playlists: [pl],
lastUsedPlaylistName: nil,
selectedPlaylist: nil,
onAddToPlaylist: { _, _ in },
onAddToLastPlaylist: nil,
onRemoveFromPlaylist: nil
)
// These must not crash
config.onAddToLastPlaylist?(track)
config.onRemoveFromPlaylist?(track)
#expect(config.lastUsedPlaylistName == nil)
#expect(config.selectedPlaylist == nil)
}
}
```
- [ ] **Step 2: Run tests to confirm they fail (type not found)**
```
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "error:|FAIL|PASS|warning: cannot"
```
Expected: compile error — `TrackContextMenuConfig` not found.
- [ ] **Step 3: Create `Music/Models/TrackContextMenuConfig.swift`**
```swift
import Foundation
struct TrackContextMenuConfig {
let playlists: [Playlist]
let lastUsedPlaylistName: String?
let selectedPlaylist: Playlist?
let onAddToPlaylist: (Track, Playlist) -> Void
let onAddToLastPlaylist: ((Track) -> Void)?
let onRemoveFromPlaylist: ((Track) -> Void)?
}
```
- [ ] **Step 4: Add the new file to the Xcode project**
In Xcode, right-click the `Models` group in the project navigator → **Add Files to "Music"** → select `TrackContextMenuConfig.swift`. Make sure "Add to targets: Music" is checked.
- [ ] **Step 5: Run tests to confirm they pass**
```
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "Test.*passed|Test.*failed|error:"
```
Expected: both tests pass.
- [ ] **Step 6: Commit**
```bash
git add Music/Models/TrackContextMenuConfig.swift MusicTests/TrackContextMenuConfigTests.swift
git commit -m "feat: add TrackContextMenuConfig value type"
```
---
### Task 2: Create `TrackContextMenuModifier`
**Files:**
- Create: `Music/Views/TrackContextMenuModifier.swift`
> No unit test for this task — SwiftUI view modifier behaviour requires UI/snapshot testing not set up in this project. Manual verification is in Task 5.
- [ ] **Step 1: Create `Music/Views/TrackContextMenuModifier.swift`**
```swift
import SwiftUI
// Attaches a context menu matching the track table's right-click menu.
// No-ops silently when track or config is nil so callers can pass optionals freely.
struct TrackContextMenuModifier: ViewModifier {
let track: Track?
let config: TrackContextMenuConfig?
func body(content: Content) -> some View {
if let track, let config {
content.contextMenu {
if let lastPlaylistName = config.lastUsedPlaylistName,
let onAddToLastPlaylist = config.onAddToLastPlaylist {
Button("Add to \(lastPlaylistName)") {
onAddToLastPlaylist(track)
}
Divider()
}
if !config.playlists.isEmpty {
Menu("Add to Playlist") {
ForEach(config.playlists) { playlist in
Button(playlist.name) {
config.onAddToPlaylist(track, playlist)
}
}
}
}
if config.selectedPlaylist != nil,
let onRemoveFromPlaylist = config.onRemoveFromPlaylist {
Divider()
Button("Remove from Playlist") {
onRemoveFromPlaylist(track)
}
}
}
} else {
content
}
}
}
extension View {
func trackContextMenu(track: Track?, config: TrackContextMenuConfig?) -> some View {
modifier(TrackContextMenuModifier(track: track, config: config))
}
}
```
- [ ] **Step 2: Add the new file to the Xcode project**
In Xcode, right-click the `Views` group → **Add Files to "Music"** → select `TrackContextMenuModifier.swift`. Make sure "Add to targets: Music" is checked.
- [ ] **Step 3: Build to confirm it compiles**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"
```
Expected: `BUILD SUCCEEDED`.
- [ ] **Step 4: Commit**
```bash
git add Music/Views/TrackContextMenuModifier.swift
git commit -m "feat: add TrackContextMenuModifier SwiftUI view modifier"
```
---
### Task 3: Refactor `TrackTableView` to use `contextMenuConfig`
**Files:**
- Modify: `Music/Views/TrackTableView.swift:45-50` (replace 6 playlist params)
- Modify: `Music/Views/TrackTableView.swift:333-394` (update `menuNeedsUpdate` + action handlers)
- [ ] **Step 1: Replace the 6 individual playlist properties with `contextMenuConfig`**
In `Music/Views/TrackTableView.swift`, find lines 45–51:
```swift
var playlists: [Playlist]
var lastUsedPlaylistName: String?
var selectedPlaylist: Playlist?
var onAddToPlaylist: ((Track, Playlist) -> Void)?
var onAddToLastPlaylist: ((Track) -> Void)?
var onRemoveFromPlaylist: ((Track) -> Void)?
var onReorder: ((Int, Int) -> Void)?
```
Replace with:
```swift
var contextMenuConfig: TrackContextMenuConfig?
var onReorder: ((Int, Int) -> Void)?
```
- [ ] **Step 2: Update `menuNeedsUpdate` to read from `contextMenuConfig`**
Find the entire `menuNeedsUpdate` method (lines ~333–375) and replace it:
```swift
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
if let lastPlaylistName = config.lastUsedPlaylistName, config.onAddToLastPlaylist != nil {
let lastItem = NSMenuItem(
title: "Add to \(lastPlaylistName)",
action: #selector(addToLastPlaylist(_:)),
keyEquivalent: ""
)
lastItem.target = self
menu.addItem(lastItem)
menu.addItem(.separator())
}
if !config.playlists.isEmpty {
let submenu = NSMenu()
for (index, playlist) in config.playlists.enumerated() {
let item = NSMenuItem(
title: playlist.name,
action: #selector(addToPlaylist(_:)),
keyEquivalent: ""
)
item.target = self
item.tag = index
submenu.addItem(item)
}
let submenuItem = NSMenuItem(title: "Add to Playlist", action: nil, keyEquivalent: "")
submenuItem.submenu = submenu
menu.addItem(submenuItem)
}
if config.selectedPlaylist != nil, config.onRemoveFromPlaylist != nil {
menu.addItem(.separator())
let removeItem = NSMenuItem(
title: "Remove from Playlist",
action: #selector(removeFromPlaylist(_:)),
keyEquivalent: ""
)
removeItem.target = self
menu.addItem(removeItem)
}
}
```
- [ ] **Step 3: Update the three `@objc` menu action methods to use `contextMenuConfig`**
Find `addToPlaylist`, `addToLastPlaylist`, and `removeFromPlaylist` (lines ~377–394) and replace all three:
```swift
@objc func addToPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
let track = tracks[tableView.clickedRow]
let playlist = config.playlists[sender.tag]
config.onAddToPlaylist(track, playlist)
}
@objc func addToLastPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
let track = tracks[tableView.clickedRow]
config.onAddToLastPlaylist?(track)
}
@objc func removeFromPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
let track = tracks[tableView.clickedRow]
config.onRemoveFromPlaylist?(track)
}
```
- [ ] **Step 4: Build — expect ContentView compile errors (call site not yet updated)**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:"
```
Expected: errors in `ContentView.swift` about removed parameters. `TrackTableView.swift` itself should be clean.
- [ ] **Step 5: Commit**
```bash
git add Music/Views/TrackTableView.swift
git commit -m "refactor: replace TrackTableView playlist params with TrackContextMenuConfig"
```
---
### Task 4: Add `contextMenuConfig` to `PlayerControlsView`
**Files:**
- Modify: `Music/Views/PlayerControlsView.swift:22` (add param after `onNowPlayingTap`)
- Modify: `Music/Views/PlayerControlsView.swift:112-118` (apply modifier to `nowPlayingSection`)
- [ ] **Step 1: Add the new parameter to `PlayerControlsView`**
In `Music/Views/PlayerControlsView.swift`, find line 22:
```swift
let onNowPlayingTap: () -> Void
```
Add after it:
```swift
let onNowPlayingTap: () -> Void
var contextMenuConfig: TrackContextMenuConfig? = nil
```
- [ ] **Step 2: Apply the modifier to `nowPlayingSection`**
In `PlayerControlsView.swift`, find the closing of `nowPlayingSection` (lines ~112–118):
```swift
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
}
```
Replace with:
```swift
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
.trackContextMenu(track: currentTrack, config: contextMenuConfig)
}
```
- [ ] **Step 3: Build — expect ContentView errors only**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:"
```
Expected: only `ContentView.swift` errors remain (old params still passed there).
- [ ] **Step 4: Commit**
```bash
git add Music/Views/PlayerControlsView.swift
git commit -m "feat: add contextMenuConfig param to PlayerControlsView"
```
---
### Task 5: Update `ContentView` — wire both call sites
**Files:**
- Modify: `Music/ContentView.swift:181-194` (TrackTableView call site)
- Modify: `Music/ContentView.swift:333-362` (PlayerControlsView call site)
- [ ] **Step 1: Replace the TrackTableView playlist params with `contextMenuConfig`**
In `Music/ContentView.swift`, find lines 181–200 (inside `TrackTableView(...)`):
```swift
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
```
Replace with:
```swift
contextMenuConfig: TrackContextMenuConfig(
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil
),
```
- [ ] **Step 2: Pass `contextMenuConfig` to `PlayerControlsView`**
In `Music/ContentView.swift`, find the `PlayerControlsView(...)` block (lines ~333–362). Add `contextMenuConfig` after `onNowPlayingTap`:
```swift
onNowPlayingTap: { scrollToPlayingTrigger = UUID() },
contextMenuConfig: TrackContextMenuConfig(
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil
)
```
- [ ] **Step 3: Build cleanly**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"
```
Expected: `BUILD SUCCEEDED` with no errors.
- [ ] **Step 4: Run the full test suite**
```
xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "Test.*passed|Test.*failed|error:|BUILD"
```
Expected: all tests pass, including `TrackContextMenuConfigTests`.
- [ ] **Step 5: Manual verification**
Run the app. With at least one playlist created:
1. **Track table:** right-click any track row → confirm the menu shows "Add to [last]" / "Add to Playlist" submenu / "Remove from Playlist" (when in a playlist view). Behaviour must be unchanged.
2. **Bottom controls:** play a track, then right-click anywhere on the now-playing area (album art + title + artist) → confirm the same menu appears.
3. **No track playing:** right-click the empty now-playing area → confirm no menu appears (the modifier is a no-op when `currentTrack` is nil).
- [ ] **Step 6: Commit**
```bash
git add Music/ContentView.swift
git commit -m "feat: wire TrackContextMenuConfig to bottom controls and track table"
```