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. NoRemoteProtocolchanges. (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
manualQueueempties,next()advances the context from the preservedcurrentIndex. - 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..onMove→moveInQueue; row × /.swipeActions→removeFromQueue. - Section "Next from: <contextName>" —
player.upcomingContext, read-only. Double-click a row to jump to it in the context (setscurrentIndexand 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
VStackholdingHomeView/TrackTableView) inHStack(spacing: 0) { mainContent; if showQueue { QueueView(player: player) } }. PlayerControlsViewgains a queue-toggle button (bottom-right of the transport bar) bound toshowQueue. The button is hidden whennetworkStatusindicates 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(_:)(AppKitNSMenuItems + actions).TrackContextMenuModifier(SwiftUIButtons).
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
RemotePlaybackProvidercase (driving a separate remote device,networkStatus.mode == .remote):next()delegates over the wire (unchanged) and never consultsmanualQueue; 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:
addToQueueappends tomanualQueue;playNextinserts at the front.next()plays the front ofmanualQueuebefore advancing the context, andremoveFirstconsumes it.- After
manualQueuedrains,next()resumes the context atcurrentIndex + 1. - Queue-while-idle (
currentTrack == nil) starts playback immediately. toggleShuffle()leavesmanualQueueorder unchanged.removeFromQueue/moveInQueuemutatemanualQueuecorrectly.upcomingContextreturns the correct slice of the context.- 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—showQueuestate, panel layout, wiring,contextNameatsetQueuecall 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.