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.
110 lines
4.6 KiB
110 lines
4.6 KiB
# Add "New Playlist…" to the Add-to-Playlist menu
|
|
|
|
**Date:** 2026-05-30
|
|
**Status:** Approved design
|
|
|
|
## Goal
|
|
|
|
In a track's right-click "Add to Playlist" submenu, let the user create a brand-new
|
|
regular playlist on the fly: pick **New Playlist…**, enter a name, and on save the
|
|
playlist is created and the track is added to it.
|
|
|
|
## Background
|
|
|
|
The "Add to Playlist" submenu lives in `TrackContextMenuModifier.swift` and is driven by
|
|
the data-only `TrackContextMenuConfig` struct (a `playlists` array plus action closures),
|
|
built in `ContentView.trackContextMenuConfig` (`ContentView.swift:415`).
|
|
|
|
The app already creates playlists from the sidebar via an `.alert` + `TextField`
|
|
(`ContentView.swift:273`), calling `PlaylistViewModel.createPlaylist(name:)` →
|
|
`DatabaseService.createPlaylist(name:) -> Playlist`. Adding a track is
|
|
`PlaylistViewModel.addTrack(_:to:)`, which also records the playlist as last-used.
|
|
|
|
Regular and smart playlists are separate tables; this feature only creates **regular**
|
|
playlists (`Playlist`).
|
|
|
|
## Approach
|
|
|
|
Route the name prompt up to `ContentView`, which already owns the new-playlist alert.
|
|
|
|
SwiftUI alerts do not present reliably when attached *inside* a context menu's content
|
|
(the menu dismisses, and the per-row modifier is re-instantiated). So the menu item only
|
|
signals intent — "create a new playlist for this track" — and `ContentView` owns the
|
|
prompt and the orchestration. This reuses the existing alert pattern rather than
|
|
duplicating it inside the modifier (the rejected alternative, which would also need the
|
|
modifier to reach into `PlaylistViewModel`).
|
|
|
|
## Behavior decisions
|
|
|
|
- Adds **only the clicked track** (matches the current single-track "Add to Playlist").
|
|
- **No navigation** — after create+add the sidebar selection is unchanged.
|
|
- Empty / whitespace-only name → no-op (matches the existing create flow).
|
|
- When the user has **no** existing playlists, the submenu still appears showing just
|
|
"New Playlist…" (today the whole submenu is hidden when `playlists` is empty).
|
|
- Remote mode → wired the same way as the existing "Add to Playlist" action
|
|
(unconditionally), keeping parity with current behavior.
|
|
|
|
## Changes
|
|
|
|
### 1. `PlaylistViewModel` (`Music/ViewModels/PlaylistViewModel.swift`)
|
|
|
|
Add one orchestration method — the unit under test:
|
|
|
|
```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`; `addTrack` adds the track
|
|
and sets `lastUsedPlaylistId` to the new playlist (so the "Add to <last>" item updates too).
|
|
|
|
### 2. `TrackContextMenuConfig` (`Music/Models/TrackContextMenuConfig.swift`)
|
|
|
|
Add a new optional closure, defaulting to `nil` (so all existing call sites and tests
|
|
compile unchanged):
|
|
|
|
```swift
|
|
let onAddToNewPlaylist: ((Track) -> Void)?
|
|
```
|
|
|
|
Add it to the explicit `init` with a `= nil` default, alongside the other optionals.
|
|
|
|
### 3. `TrackContextMenuModifier` (`Music/Views/TrackContextMenuModifier.swift`)
|
|
|
|
Inside the "Add to Playlist" submenu:
|
|
|
|
- Add a **New Playlist…** button at the top (ellipsis = opens a prompt) when
|
|
`onAddToNewPlaylist != nil`, followed by a `Divider` before the list of existing
|
|
playlists.
|
|
- Relax the visibility guard so the submenu shows when
|
|
`!config.playlists.isEmpty || config.onAddToNewPlaylist != nil` (previously hidden
|
|
whenever `playlists` was empty).
|
|
|
|
### 4. `ContentView` (`Music/ContentView.swift`)
|
|
|
|
- Add state: `@State private var newPlaylistTrack: Track?` and a name field
|
|
(reuse/parallel the existing `playlistNameInput` style; a dedicated field is fine).
|
|
- In `trackContextMenuConfig`, wire `onAddToNewPlaylist: { track in newPlaylistTrack = track }`.
|
|
- Present an alert (mirroring the existing New Playlist alert) gated on
|
|
`newPlaylistTrack != nil`. **Create** trims whitespace, and if non-empty calls
|
|
`playlist.createPlaylistAndAddTrack(name:track:)` with the pending track; **Cancel** and
|
|
completion both clear `newPlaylistTrack` and the name field.
|
|
|
|
## Testing (TDD)
|
|
|
|
Unit-test `PlaylistViewModel.createPlaylistAndAddTrack(name:track:)` against an in-memory
|
|
database:
|
|
|
|
1. Seed a track in the DB.
|
|
2. Call `createPlaylistAndAddTrack(name:track:)`.
|
|
3. Assert a regular playlist with that name now exists.
|
|
4. Assert the playlist's tracks contain the seeded track.
|
|
5. Assert `lastUsedPlaylistId` equals the new playlist's id.
|
|
|
|
The SwiftUI menu/alert rendering is not unit-tested, consistent with the rest of the
|
|
codebase. `TrackContextMenuConfig`'s new optional defaults to `nil`, so existing
|
|
`TrackContextMenuConfigTests` are unaffected.
|
|
|