# 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: " ``` 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: \"** — `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.