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

86 lines
3.6 KiB

---
title: Track Context Menu on Bottom Controls
date: 2026-05-30
status: 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:
```swift
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:
```swift
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:
```swift
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