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-30-track-context-me...

3.6 KiB

title date status
Track Context Menu on Bottom Controls 2026-05-30 approved

Goal

Right-clicking the now-playing area (bottom-left of the window) shows the same context menu as right-clicking a track in the track table: add to last playlist, add to any playlist via submenu, and remove from the current playlist when one is open.

Shared Config Struct

A new TrackContextMenuConfig struct captures everything the menu needs:

struct TrackContextMenuConfig {
    let playlists: [Playlist]
    let lastUsedPlaylistName: String?
    let selectedPlaylist: Playlist?
    let onAddToPlaylist: (Track, Playlist) -> Void
    let onAddToLastPlaylist: ((Track) -> Void)?
    let onRemoveFromPlaylist: ((Track) -> Void)?
}

This is the single source of truth for menu data. Both TrackTableView and PlayerControlsView receive one instance, constructed by ContentView.

Shared ViewModifier

TrackContextMenuModifier is a SwiftUI ViewModifier that takes a Track? and TrackContextMenuConfig? and applies .contextMenu when both are non-nil:

  • "Add to [last]" button — shown only when lastUsedPlaylistName is set and onAddToLastPlaylist is non-nil.
  • "Add to Playlist" submenu — one Button per playlist in playlists. Calls onAddToPlaylist(track, playlist).
  • Divider + "Remove from Playlist" — shown only when selectedPlaylist != nil and onRemoveFromPlaylist is non-nil.

Menu is omitted entirely (no empty menu flicker) when track or config is nil.

PlayerControlsView Changes

PlayerControlsView gains one new optional parameter:

let contextMenuConfig: TrackContextMenuConfig?

The nowPlayingSection view applies .trackContextMenu(track: currentTrack, config: contextMenuConfig) (a convenience extension wrapping TrackContextMenuModifier).

TrackTableView Refactor

TrackTableView's existing four playlist-related parameters (playlists, lastUsedPlaylistName, selectedPlaylist, onAddToLastPlaylist, onRemoveFromPlaylist) are replaced by a single contextMenuConfig: TrackContextMenuConfig?. The Coordinator.menuNeedsUpdate builds its NSMenu from this config. This makes both call sites symmetric.

The AppKit NSMenu path in TrackTableView is kept — SwiftUI .contextMenu does not attach per-row in an NSTableView, so the table continues using menuNeedsUpdate.

ContentView Changes

ContentView constructs one TrackContextMenuConfig and passes it to both views:

let menuConfig = TrackContextMenuConfig(
    playlists: playlist.allPlaylists,
    lastUsedPlaylistName: playlist.lastUsedPlaylistName,
    selectedPlaylist: playlist.selectedPlaylist,
    onAddToPlaylist: { track, pl in try? playlist.addTrack(track, to: pl) },
    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
)

Files Affected

File Change
Music/Models/TrackContextMenuConfig.swift New file — struct definition
Music/Views/TrackContextMenuModifier.swift New file — SwiftUI ViewModifier
Music/Views/PlayerControlsView.swift Add contextMenuConfig param, apply modifier to nowPlayingSection
Music/Views/TrackTableView.swift Replace individual playlist params with contextMenuConfig, adapt menuNeedsUpdate
Music/ContentView.swift Construct and pass TrackContextMenuConfig to both views

Out of Scope

  • Keyboard shortcut for the menu
  • Any new menu items not already in the track table menu