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-playing-queue-de...

243 lines
9.9 KiB

# Playing Queue — Design
**Date:** 2026-05-30
**Status:** Approved (pending spec review)
## Overview
Add a Spotify-style **priority "Up Next" queue** to the Music app. Users can push
tracks to the front ("Play Next") or end ("Add to Queue") of a manual queue via the
track context menu. The manual queue plays before the current playback context
(playlist/album) resumes, survives starting a new context, and is visible and
editable in a right-docked "Up Next" panel.
## Goals
- "Play Next" and "Add to Queue" actions on the track context menu.
- A manual queue that takes priority over the playback context and persists when a
new context (playlist/album/library) starts playing.
- A visible, right-docked "Up Next" panel showing the manual queue and the upcoming
context tracks.
- Drag-to-reorder and remove **within the manual queue**.
## Non-Goals (v1)
- **Remote/streaming support.** When this app is driving a remote device, queue
actions are hidden and `next()` continues to delegate over the wire. No
`RemoteProtocol` changes. (Possible follow-up.)
- **Persistence across app restart.** The queue is in-memory in `PlayerViewModel`.
- **Reordering the context** from the panel (that is playlist editing, already
handled elsewhere). The "Next from" section is read-only.
- **Clear-all-queue** action (not requested).
- **Multi-select queueing.** Actions operate on the single right-clicked track,
consistent with the existing "Add to Playlist".
## Resolved Decisions
| Decision | Choice | Reason |
|---|---|---|
| Queue model | Spotify-style priority queue (manual queue distinct from context) | User selection |
| Panel placement | Right-docked slide-out, toggled from transport bar | User selection (mockup A) |
| Remote scope | Local-only for v1; hide actions in remote mode | User selection |
| Persistence | In-memory only | User selection |
| `queue`/`currentIndex` naming | Keep as the **context**; add doc comments | Backward-compatible; avoids touching remote-sync + existing tests |
| "Next from" section | Read-only, double-click to jump | Context reordering is out of scope |
## Data Model (`PlayerViewModel`)
Existing `queue` / `originalQueue` / `currentIndex` are **retained and keep their
current meaning: the playback CONTEXT** (playlist/album, with shuffle applied). New
state is added alongside:
```swift
private(set) var queue: [Track] // UNCHANGED — the context (shuffled view)
private var originalQueue: [Track] // UNCHANGED — context, original order
var currentIndex: Int? // UNCHANGED — index into `queue` (the CONTEXT
// position), held even while a manual track plays
private(set) var manualQueue: [QueueEntry] = [] // NEW — priority "Up Next" entries
private(set) var contextName: String? // NEW — label for "Next from: <name>"
```
Each manual entry carries its own identity so the same track can be queued twice
without SwiftUI confusing the rows (the codebase has prior id-collision bugs):
```swift
nonisolated struct QueueEntry: Identifiable {
let id = UUID()
let track: Track
}
```
A dedicated `playManual(_:)` path plays a queued track **without** touching
`currentIndex`, so the context position is preserved automatically — no extra "am I
playing from the queue?" flag is needed.
Computed for the panel:
```swift
// Context tracks after the current context position; the "Next from" section.
var upcomingContext: [Track] {
guard let idx = currentIndex, idx + 1 < queue.count else { return [] }
return Array(queue[(idx + 1)...])
}
```
`currentIndex` deliberately tracks the **context** position, not "what is playing."
While a manual-queue track plays, `currentIndex` stays put so the context resumes at
the correct spot once the manual queue drains.
## Behavior
### Adding to the queue
```swift
func playNext(_ track: Track) // insert at front of manualQueue
func addToQueue(_ track: Track) // append to end of manualQueue
```
Both: if **nothing is currently playing** (`currentTrack == nil`), immediately pop
and play the just-queued track instead of leaving it parked (queue-while-idle starts
playback).
### Advancing
```swift
func next() {
if remoteProvider != nil { remote.sendNext(); return } // UNCHANGED remote path
if !manualQueue.isEmpty {
let entry = manualQueue.removeFirst() // consume-on-play
playManual(entry.track) // currentIndex unchanged
} else {
// existing context-advance logic (currentIndex + 1, stop at end)
}
}
```
- **Consume-on-play:** `removeFirst()` removes the track from "Up Next" the instant
it starts. The Queue section only ever shows not-yet-played tracks.
- **Resume point:** when `manualQueue` empties, `next()` advances the context from
the preserved `currentIndex`.
- Triggered identically by user-pressed Next and by auto-advance (`trackDidFinish`).
### Previous
Unchanged: steps back through the **context** (`currentIndex − 1`, clamped at 0).
It never re-adds consumed queue items and does not consult `manualQueue` (accepted
v1 simplification).
### Shuffle
Unchanged: only the context (`queue`) is shuffled. `manualQueue` order is always
preserved.
### Explicit play / new context
`play(_:)` (double-click a row, space-bar, Home) sets a new **context** via
`setQueue(_:contextName:)` and plays from it (setting `currentIndex`). The manual
queue is **not** cleared — it survives the new context, matching the chosen Spotify
model.
### Queue editing
```swift
func moveInQueue(from: IndexSet, to: Int) // reorder manualQueue (panel drag)
func removeFromQueue(at: IndexSet) // remove from manualQueue (panel × / swipe)
```
## UI
### `setQueue` signature change
```swift
func setQueue(_ tracks: [Track], contextName: String? = nil)
```
Call sites in `ContentView` pass the context label: playlist/smart-playlist name,
`"Library"`, or `"Recently Added"` (Home). Default `nil` keeps existing tests and
any unlabeled callers compiling.
### Up Next panel — new `QueueView`
A SwiftUI `List`, docked on the right of the main content:
- **Section "Queue"** — `player.manualQueue`. `.onMove``moveInQueue`; row × /
`.swipeActions``removeFromQueue`.
- **Section "Next from: \<contextName\>"** — `player.upcomingContext`, read-only.
Double-click a row to jump to it in the context (sets `currentIndex` and plays).
- **Empty state** — "Queue is empty. Right-click a track → Add to Queue."
### Integration into `ContentView`
- New `@State private var showQueue = false`.
- Wrap the main-content region (the `VStack` holding `HomeView`/`TrackTableView`) in
`HStack(spacing: 0) { mainContent; if showQueue { QueueView(player: player) } }`.
- `PlayerControlsView` gains a queue-toggle button (bottom-right of the transport
bar) bound to `showQueue`. The button is **hidden when `networkStatus` indicates
remote-drive mode**.
### Context menu — `TrackContextMenuConfig`
Add two optional closures:
```swift
let onPlayNext: ((Track) -> Void)?
let onAddToQueue: ((Track) -> Void)?
```
Rendered in **both** menu builders, as a group above "Add to Playlist":
- `TrackTableView.Coordinator.menuNeedsUpdate(_:)` (AppKit `NSMenuItem`s + actions).
- `TrackContextMenuModifier` (SwiftUI `Button`s).
Each is shown only when its closure is non-nil. In `ContentView.trackContextMenuConfig`
they are wired to `player.playNext` / `player.addToQueue`, and passed as **`nil`
when driving a remote device** so the items are hidden.
## Remote / Edge Handling (local-only v1)
- The disable gate is specifically the **`RemotePlaybackProvider`** case (driving a
separate remote device, `networkStatus.mode == .remote`): `next()` delegates over
the wire (unchanged) and never consults `manualQueue`; queue menu items hidden (nil
closures); queue-toggle button hidden. No protocol changes.
- **Streaming client** mode plays locally through `StreamingPlaybackProvider`, so it
is **not** gated — the queue works there, as it does for local and streaming-host
playback.
- Empty manual queue + empty upcoming context: panel shows empty state; `next()`
at the end of the context stops, as today.
## Testing (TDD)
New `PlayerViewModelTests` cases (reusing the `AudioService()` / `FakeStreamingProvider`
pattern already in the file). Each test carries a step-by-step comment:
1. `addToQueue` appends to `manualQueue`; `playNext` inserts at the front.
2. `next()` plays the front of `manualQueue` before advancing the context, and
`removeFirst` consumes it.
3. After `manualQueue` drains, `next()` resumes the context at `currentIndex + 1`.
4. Queue-while-idle (`currentTrack == nil`) starts playback immediately.
5. `toggleShuffle()` leaves `manualQueue` order unchanged.
6. `removeFromQueue` / `moveInQueue` mutate `manualQueue` correctly.
7. `upcomingContext` returns the correct slice of the context.
8. Existing PlayerViewModel tests remain green (backward-compatible model).
New `TrackContextMenuConfigTests` case: the `onPlayNext` / `onAddToQueue` closures
fire with the expected track.
## Files Touched
- `Music/ViewModels/PlayerViewModel.swift` — state, `playNext`/`addToQueue`/
`moveInQueue`/`removeFromQueue`, `next()` priority logic, `upcomingContext`,
`setQueue(contextName:)`.
- `Music/Models/QueueEntry.swift`**new** identity wrapper for queued tracks.
- `Music/Models/TrackContextMenuConfig.swift` — two new closures.
- `Music/Views/TrackTableView.swift` — AppKit menu items + actions.
- `Music/Views/TrackContextMenuModifier.swift` — SwiftUI menu buttons.
- `Music/Views/PlayerControlsView.swift` — queue-toggle button.
- `Music/Views/QueueView.swift`**new** Up Next panel.
- `Music/ContentView.swift``showQueue` state, panel layout, wiring, `contextName`
at `setQueue` call sites.
- `MusicTests/PlayerViewModelTests.swift`, `MusicTests/TrackContextMenuConfigTests.swift`
— new tests.
No `project.pbxproj` edit is needed: the project uses Xcode 16 file-system
synchronized groups, so new files under `Music/` are picked up automatically.