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:
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
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
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
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
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(updatemenuNeedsUpdate+ action handlers) -
Step 1: Replace the 6 individual playlist properties with
contextMenuConfig
In Music/Views/TrackTableView.swift, find lines 45–51:
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:
var contextMenuConfig: TrackContextMenuConfig?
var onReorder: ((Int, Int) -> Void)?
- Step 2: Update
menuNeedsUpdateto read fromcontextMenuConfig
Find the entire menuNeedsUpdate method (lines ~333–375) and replace it:
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
@objcmenu action methods to usecontextMenuConfig
Find addToPlaylist, addToLastPlaylist, and removeFromPlaylist (lines ~377–394) and replace all three:
@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
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 afteronNowPlayingTap) -
Modify:
Music/Views/PlayerControlsView.swift:112-118(apply modifier tonowPlayingSection) -
Step 1: Add the new parameter to
PlayerControlsView
In Music/Views/PlayerControlsView.swift, find line 22:
let onNowPlayingTap: () -> Void
Add after it:
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):
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
}
Replace with:
.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
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(...)):
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:
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
contextMenuConfigtoPlayerControlsView
In Music/ContentView.swift, find the PlayerControlsView(...) block (lines ~333–362). Add contextMenuConfig after onNowPlayingTap:
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:
- 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.
- Bottom controls: play a track, then right-click anywhere on the now-playing area (album art + title + artist) → confirm the same menu appears.
- No track playing: right-click the empty now-playing area → confirm no menu appears (the modifier is a no-op when
currentTrackis nil).
- Step 6: Commit
git add Music/ContentView.swift
git commit -m "feat: wire TrackContextMenuConfig to bottom controls and track table"