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.
243 lines
9.9 KiB
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.
|
|
|