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-add-to-new-playl...

327 lines
13 KiB

# Add "New Playlist…" to the Add-to-Playlist menu — 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:** Let a user create a new regular playlist from a track's "Add to Playlist" context submenu, name it via a prompt, and have the track added to it on save.
**Architecture:** A new "New Playlist…" item in the existing `Menu("Add to Playlist")` (in `TrackContextMenuModifier`) calls a new optional closure on `TrackContextMenuConfig`. `ContentView` owns that closure: it stashes the pending track and presents an `.alert` + `TextField` (mirroring the app's existing New Playlist alert). On save it calls a new `PlaylistViewModel.createPlaylistAndAddTrack(name:track:)`, which creates the regular playlist and adds the track in one step.
**Tech Stack:** Swift, SwiftUI, GRDB, Swift Testing (`@Test`), Xcode (`Music` scheme).
**Git note:** This project's owner never auto-commits — commits are made by the user via the `/commit` skill. The "Commit" steps below describe the *suggested grouping* of changes for when the user chooses to commit; do **not** run `git commit` yourself.
**Spec:** `docs/superpowers/specs/2026-05-30-add-to-new-playlist-design.md`
---
## File Structure
- `Music/ViewModels/PlaylistViewModel.swift`**Modify.** Add `createPlaylistAndAddTrack(name:track:)` orchestration method.
- `MusicTests/PlaylistViewModelTests.swift`**Create.** Unit test for the new method.
- `Music/Models/TrackContextMenuConfig.swift`**Modify.** Add `onAddToNewPlaylist` optional closure (+ init param, default `nil`).
- `Music/Views/TrackContextMenuModifier.swift`**Modify.** Add "New Playlist…" button + relax the submenu visibility guard.
- `Music/ContentView.swift`**Modify.** New `@State` for the pending track, wire `onAddToNewPlaylist`, present the name alert.
---
## Task 1: `PlaylistViewModel.createPlaylistAndAddTrack`
**Files:**
- Test: `MusicTests/PlaylistViewModelTests.swift` (create)
- Modify: `Music/ViewModels/PlaylistViewModel.swift` (add method after `addTrack`, ~line 77)
- [ ] **Step 1: Write the failing test**
Create `MusicTests/PlaylistViewModelTests.swift`:
```swift
import Testing
import Foundation
@testable import Music
@MainActor
struct PlaylistViewModelTests {
// Verifies createPlaylistAndAddTrack does the full job in one call:
// 1. Seed a track into an in-memory DB and build a PlaylistViewModel over it.
// 2. Call createPlaylistAndAddTrack with a name and the seeded track.
// 3. The returned playlist has the given name and a real (non-nil) id.
// 4. The DB shows that playlist now contains exactly the seeded track.
// 5. The new playlist is recorded as the last-used playlist.
@Test func createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack() throws {
// 1. Seed a track and build the view model.
let db = try DatabaseService(inMemory: true)
var track = Track.fixture(fileURL: "/song.mp3", title: "Song A")
try db.insert(&track)
let vm = PlaylistViewModel(db: db)
// 2. Create a new playlist and add the track in one step.
let created = try vm.createPlaylistAndAddTrack(name: "Road Trip", track: track)
// 3. The returned playlist is well-formed.
#expect(created.id != nil)
#expect(created.name == "Road Trip")
// 4. The playlist contains exactly the seeded track.
let tracks = try db.fetchPlaylistTracks(playlistId: created.id!)
#expect(tracks.count == 1)
#expect(tracks[0].id == track.id)
// 5. The new playlist became the last-used playlist.
#expect(vm.lastUsedPlaylistId == created.id)
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlaylistViewModelTests
```
Expected: **build/compile failure**`value of type 'PlaylistViewModel' has no member 'createPlaylistAndAddTrack'`.
- [ ] **Step 3: Write the minimal implementation**
In `Music/ViewModels/PlaylistViewModel.swift`, add this method directly after `addTrack(_:to:)` (after line 77):
```swift
@discardableResult
func createPlaylistAndAddTrack(name: String, track: Track) throws -> Playlist {
let playlist = try db.createPlaylist(name: name)
try addTrack(track, to: playlist)
return playlist
}
```
`db.createPlaylist` returns a `Playlist` with its assigned `id`; the existing `addTrack` inserts the join row and sets `lastUsedPlaylistId`.
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlaylistViewModelTests
```
Expected: **PASS** (`createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack`).
- [ ] **Step 5: Commit (suggested grouping — leave to the user / `/commit`)**
Changed files: `MusicTests/PlaylistViewModelTests.swift`, `Music/ViewModels/PlaylistViewModel.swift`.
Suggested message: `feat: add PlaylistViewModel.createPlaylistAndAddTrack`
---
## Task 2: Add `onAddToNewPlaylist` to `TrackContextMenuConfig`
This is a pure data struct; the new field is optional with a `nil` default, so existing call sites and `TrackContextMenuConfigTests` keep compiling. No new unit test (the struct just stores a closure).
**Files:**
- Modify: `Music/Models/TrackContextMenuConfig.swift`
- [ ] **Step 1: Add the stored property**
In `TrackContextMenuConfig`, add the property after `onAddToQueue` (after line 15):
```swift
// nil hides the "New Playlist…" item (e.g. tests that don't supply it).
let onAddToNewPlaylist: ((Track) -> Void)?
```
- [ ] **Step 2: Add the init parameter (with default `nil`)**
In the explicit `init`, add the parameter after `onAddToQueue` (line 30):
```swift
onAddToQueue: ((Track) -> Void)? = nil,
onAddToNewPlaylist: ((Track) -> Void)? = nil,
onGetInfo: (([Track]) -> Void)? = nil
```
And add the assignment in the body, after `self.onAddToQueue = onAddToQueue` (line 40):
```swift
self.onAddToNewPlaylist = onAddToNewPlaylist
```
- [ ] **Step 3: Build to verify it compiles**
Run:
```bash
xcodebuild build -scheme Music -destination 'platform=macOS'
```
Expected: **BUILD SUCCEEDED** (existing call sites still compile because the new param defaults to `nil`).
- [ ] **Step 4: Run the existing config tests to confirm no regression**
Run:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests
```
Expected: **PASS** (all existing tests).
- [ ] **Step 5: Commit (suggested grouping — leave to the user / `/commit`)**
Changed file: `Music/Models/TrackContextMenuConfig.swift`.
Suggested message: `feat: add onAddToNewPlaylist to TrackContextMenuConfig`
---
## Task 3: Add "New Playlist…" to the submenu
SwiftUI view code; no unit test (consistent with the codebase). Verify by build.
**Files:**
- Modify: `Music/Views/TrackContextMenuModifier.swift:30-38`
- [ ] **Step 1: Replace the "Add to Playlist" submenu block**
Replace the current block (lines 30–38):
```swift
if !config.playlists.isEmpty {
Menu("Add to Playlist") {
ForEach(config.playlists) { playlist in
Button(playlist.name) {
config.onAddToPlaylist(track, playlist)
}
}
}
}
```
with:
```swift
if !config.playlists.isEmpty || config.onAddToNewPlaylist != nil {
Menu("Add to Playlist") {
if let onAddToNewPlaylist = config.onAddToNewPlaylist {
Button("New Playlist…") {
onAddToNewPlaylist(track)
}
if !config.playlists.isEmpty {
Divider()
}
}
ForEach(config.playlists) { playlist in
Button(playlist.name) {
config.onAddToPlaylist(track, playlist)
}
}
}
}
```
Notes: the button label uses a real ellipsis character `…` (macOS convention for "opens a prompt"). The submenu now appears even when the user has zero playlists, showing just "New Playlist…". The `Divider` only appears when there are existing playlists to separate from.
- [ ] **Step 2: Build to verify it compiles**
Run:
```bash
xcodebuild build -scheme Music -destination 'platform=macOS'
```
Expected: **BUILD SUCCEEDED**.
- [ ] **Step 3: Commit (suggested grouping — leave to the user / `/commit`)**
Changed file: `Music/Views/TrackContextMenuModifier.swift`.
Suggested message: `feat: add "New Playlist…" item to Add to Playlist submenu`
---
## Task 4: Wire the prompt in `ContentView`
SwiftUI view code; no unit test. Verify by build + manual check.
**Files:**
- Modify: `Music/ContentView.swift` — add `@State` (near the other playlist alert state, e.g. by `playlistNameInput`/`showNewPlaylistAlert`), wire the closure in `trackContextMenuConfig` (line 433–434 area), add the alert (by the existing New Playlist alert at line 273).
- [ ] **Step 1: Add state for the pending track**
Find the existing `@State` declarations for the playlist alerts (the same scope that declares `showNewPlaylistAlert` and `playlistNameInput`). Add nearby:
```swift
@State private var newPlaylistTrack: Track?
@State private var newPlaylistNameInput = ""
```
(`newPlaylistNameInput` is kept separate from the sidebar's `playlistNameInput` so the two flows can't clobber each other's text.)
- [ ] **Step 2: Wire the closure in `trackContextMenuConfig`**
In `trackContextMenuConfig` (line 412), add `onAddToNewPlaylist` to the `TrackContextMenuConfig(...)` call. Insert it after the `onAddToQueue:` argument (line 433), before `onGetInfo:`:
```swift
onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil,
onAddToNewPlaylist: { track in newPlaylistTrack = track },
onGetInfo: { tracks in
if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) }
}
```
(Wired unconditionally — matches `onAddToPlaylist`, which is also not gated on `queueEnabled`.)
- [ ] **Step 3: Add the name-prompt alert**
Immediately after the existing New Playlist alert block (after line 283, the closing `}` of `.alert("New Playlist", isPresented: $showNewPlaylistAlert)`), add:
```swift
.alert("New Playlist", isPresented: Binding(
get: { newPlaylistTrack != nil },
set: { if !$0 { newPlaylistTrack = nil; newPlaylistNameInput = "" } }
)) {
TextField("Playlist name", text: $newPlaylistNameInput)
Button("Cancel", role: .cancel) {
newPlaylistNameInput = ""
newPlaylistTrack = nil
}
Button("Create") {
let name = newPlaylistNameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty, let track = newPlaylistTrack {
try? playlist.createPlaylistAndAddTrack(name: name, track: track)
}
newPlaylistNameInput = ""
newPlaylistTrack = nil
}
}
```
- [ ] **Step 4: Build to verify it compiles**
Run:
```bash
xcodebuild build -scheme Music -destination 'platform=macOS'
```
Expected: **BUILD SUCCEEDED**.
- [ ] **Step 5: Manual verification**
Launch the app. Right-click a track → **Add to Playlist****New Playlist…**. Enter a name, click **Create**. Confirm:
- A new playlist with that name appears in the sidebar.
- Selecting it shows the track you added.
- The sidebar selection did **not** change automatically (stayed on the current view).
- Re-open the context menu: "Add to <new name>" now appears as the last-used playlist shortcut.
- Empty name → clicking Create does nothing (no empty playlist created).
- [ ] **Step 6: Commit (suggested grouping — leave to the user / `/commit`)**
Changed file: `Music/ContentView.swift`.
Suggested message: `feat: prompt for name and add track when creating playlist from menu`
---
## Final verification
- [ ] Run the full test target once:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS'
```
Expected: **all tests PASS**, including the new `PlaylistViewModelTests`.
---
## Self-review (done while writing this plan)
- **Spec coverage:** view-model create+add (Task 1), config closure (Task 2), submenu item + empty-list handling (Task 3), prompt + wiring + behavior decisions: single clicked track, no navigation, empty-name no-op (Task 4). All spec sections map to a task.
- **Placeholder scan:** none — every code step shows the exact code.
- **Type/name consistency:** `createPlaylistAndAddTrack(name:track:)` and `onAddToNewPlaylist` are used identically across Tasks 1–4; `newPlaylistTrack` / `newPlaylistNameInput` are consistent within Task 4.