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.
525 lines
19 KiB
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"
|
|
```
|
|
|