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

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 (update menuNeedsUpdate + 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 menuNeedsUpdate to read from contextMenuConfig

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 @objc menu action methods to use contextMenuConfig

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

    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 contextMenuConfig to PlayerControlsView

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:

  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
git add Music/ContentView.swift
git commit -m "feat: wire TrackContextMenuConfig to bottom controls and track table"