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...

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:

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):

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:

// 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

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

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

func moveInQueue(from: IndexSet, to: Int)  // reorder manualQueue (panel drag)
func removeFromQueue(at: IndexSet)         // remove from manualQueue (panel × / swipe)

UI

setQueue signature change

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. .onMovemoveInQueue; row × / .swipeActionsremoveFromQueue.
  • 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:

let onPlayNext: ((Track) -> Void)?
let onAddToQueue: ((Track) -> Void)?

Rendered in both menu builders, as a group above "Add to Playlist":

  • TrackTableView.Coordinator.menuNeedsUpdate(_:) (AppKit NSMenuItems + actions).
  • TrackContextMenuModifier (SwiftUI Buttons).

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.swiftnew 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.swiftnew Up Next panel.
  • Music/ContentView.swiftshowQueue 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.