30 KiB
Playing Queue Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a Spotify-style priority "Up Next" queue: tracks can be pushed to the front ("Play Next") or end ("Add to Queue") via the track context menu, played before the playlist/album context resumes, and managed in a right-docked panel.
Architecture: PlayerViewModel keeps its existing queue/currentIndex as the playback context and gains a parallel manualQueue of QueueEntry (a track + stable UUID). next() drains the manual queue before advancing the context; a dedicated playManual(_:) plays a queued track without moving currentIndex, so the context resumes correctly. A new QueueView renders the panel; the context menu and ContentView are wired up. Local-only for v1 — queue actions are hidden when driving a remote device.
Tech Stack: Swift, SwiftUI + AppKit (NSTableView), Swift Testing (import Testing), Xcode 16 synchronized groups (no pbxproj edits for new files).
Spec: docs/superpowers/specs/2026-05-30-playing-queue-design.md
Project rule — commits: This repo's CLAUDE.md says never commit unless triggered by the
/commitskill. The "Commit" steps below are checkpoints: stage the listed files and ask the user to run/commit(or run it when they direct). Do not commit autonomously.
Test command:
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:<TestTarget/Suite> 2>&1 | tail -30. Full build:xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20.
Task 1: QueueEntry model + queue state and add actions
Files:
-
Create:
Music/Models/QueueEntry.swift -
Modify:
Music/ViewModels/PlayerViewModel.swift(add state + methods) -
Test:
MusicTests/PlayerViewModelTests.swift -
Step 1: Write the failing tests
Add these tests inside the PlayerViewModelTests struct in MusicTests/PlayerViewModelTests.swift (after the existing tests, before the closing } of the struct):
// Step 1: context [1,2,3], track 1 playing.
// Step 2: addToQueue twice → manual queue holds those tracks in arrival order.
// Step 3: playNext jumps a track to the FRONT of the manual queue.
@Test func addToQueueAppendsAndPlayNextInsertsFront() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[3]) // id 4
vm.addToQueue(tracks[4]) // id 5
#expect(vm.manualQueue.map { $0.track.id } == [4, 5])
vm.playNext(tracks[5]) // id 6 to the front
#expect(vm.manualQueue.map { $0.track.id } == [6, 4, 5])
}
// Step 1: a view model with nothing playing (idle).
// Step 2: addToQueue should start playback immediately (queue-while-idle) and
// leave the manual queue empty because the track was consumed to play.
@Test func queueWhileIdleStartsPlayback() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let track = Track.fixture(id: 1, fileURL: "/a.mp3", title: "A")
vm.addToQueue(track)
#expect(vm.currentTrack?.id == 1)
#expect(vm.manualQueue.isEmpty)
}
- Step 2: Run the tests to verify they fail
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30
Expected: FAIL to compile — value of type 'PlayerViewModel' has no member 'manualQueue' / 'addToQueue' / 'playNext'.
- Step 3: Create the
QueueEntrymodel
Create Music/Models/QueueEntry.swift:
import Foundation
// A single slot in the manual "Up Next" queue. Carries its own stable identity so
// the same track can be queued more than once without SwiftUI confusing the rows —
// Track.id alone is not unique across duplicate queue entries.
nonisolated struct QueueEntry: Identifiable {
let id = UUID()
let track: Track
}
- Step 4: Add queue state to
PlayerViewModel
In Music/ViewModels/PlayerViewModel.swift, add the new stored properties immediately after the originalQueue line (currently line 20):
private var originalQueue: [Track] = []
/// The manual "Up Next" queue. Plays ahead of `queue` (the context) and survives
/// starting a new context. `queue`/`currentIndex` remain the CONTEXT position.
private(set) var manualQueue: [QueueEntry] = []
/// Display label for the panel's "Next from: <name>" section.
private(set) var contextName: String?
- Step 5: Add the manual-queue methods and
playManual
In the same file, add a new section just after the // MARK: - Queue Management block (after setQueue's closing brace, currently line 99). Note the remoteProvider == nil guard — the manual queue is local-only for v1:
// MARK: - Manual Queue
func playNext(_ track: Track) {
guard remoteProvider == nil else { return }
manualQueue.insert(QueueEntry(track: track), at: 0)
startQueuedTrackIfIdle()
}
func addToQueue(_ track: Track) {
guard remoteProvider == nil else { return }
manualQueue.append(QueueEntry(track: track))
startQueuedTrackIfIdle()
}
func removeFromQueue(at offsets: IndexSet) {
manualQueue.remove(atOffsets: offsets)
}
func moveInQueue(from source: IndexSet, to destination: Int) {
manualQueue.move(fromOffsets: source, toOffset: destination)
}
/// Context tracks after the current context position — the panel's "Next from"
/// section. Empty when there is no context or we are at its end.
var upcomingContext: [Track] {
guard let idx = currentIndex, idx + 1 < queue.count else { return [] }
return Array(queue[(idx + 1)...])
}
// If nothing is playing, start the just-queued track immediately rather than
// parking it — matches Spotify's "queue while idle starts playback".
private func startQueuedTrackIfIdle() {
guard currentTrack == nil, !manualQueue.isEmpty else { return }
let entry = manualQueue.removeFirst()
playManual(entry.track)
}
// Plays a track pulled from the manual queue. Mirrors play(_:) but deliberately
// does NOT touch currentIndex, so the context position is preserved and resumes
// once the manual queue drains.
private func playManual(_ track: Track) {
currentTrack = track
halfwayReported = false
isPlaying = true
currentTime = 0
duration = track.duration
guard let url = provider.urlForTrack(track) else { return }
provider.play(url: url)
}
- Step 6: Run the tests to verify they pass
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30
Expected: PASS (the two new tests plus all existing PlayerViewModel tests).
- Step 7: Commit (checkpoint — via
/commit)
Stage and request a commit:
git add Music/Models/QueueEntry.swift Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift
# Then ask the user to run /commit (suggested message: "feat: add manual queue state and Play Next / Add to Queue to PlayerViewModel")
Task 2: next() drains the manual queue, then resumes the context
Files:
-
Modify:
Music/ViewModels/PlayerViewModel.swift:160-172(thenext()method) -
Test:
MusicTests/PlayerViewModelTests.swift -
Step 1: Write the failing tests
Add to PlayerViewModelTests:
// Step 1: context [1,2,3], track 1 playing (currentIndex 0).
// Step 2: queue track 5. next() must play the QUEUED track, not context track 2,
// consume it from the queue, and leave currentIndex at 0 (context held).
@Test func nextConsumesManualQueueBeforeContext() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[4]) // id 5
vm.next()
#expect(vm.currentTrack?.id == 5)
#expect(vm.manualQueue.isEmpty)
#expect(vm.currentIndex == 0)
}
// Step 1: context [1,2,3], track 1 playing; queue track 5.
// Step 2: first next() plays the queued track 5 (context still at index 0).
// Step 3: second next() finds the queue empty and resumes the context at index 1
// → track 2.
@Test func contextResumesAfterManualQueueDrains() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[4]) // id 5
vm.next() // plays queued track 5
vm.next() // resumes context
#expect(vm.currentTrack?.id == 2)
#expect(vm.currentIndex == 1)
}
- Step 2: Run the tests to verify they fail
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests/nextConsumesManualQueueBeforeContext 2>&1 | tail -30
Expected: FAIL — next() currently advances the context, so currentTrack?.id is 2, not 5.
- Step 3: Update
next()
In Music/ViewModels/PlayerViewModel.swift, replace the existing next() method:
func next() {
if let remote = remoteProvider {
remote.sendNext()
return
}
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {
play(queue[nextIdx])
} else {
stop()
}
}
with:
func next() {
if let remote = remoteProvider {
remote.sendNext()
return
}
// Manual queue takes priority and is consumed as it plays.
if !manualQueue.isEmpty {
let entry = manualQueue.removeFirst()
playManual(entry.track)
return
}
// Otherwise advance the context from the preserved position.
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {
play(queue[nextIdx])
} else {
stop()
}
}
- Step 4: Run the tests to verify they pass
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30
Expected: PASS (new tests + existing tests, including nextAtEndStops and nextAdvancesToNextTrack, still green).
- Step 5: Commit (checkpoint — via
/commit)
git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift
# Suggested message: "feat: next() drains the manual queue before advancing the context"
Task 3: Edit ops, upcomingContext, setQueue(contextName:), shuffle isolation
Files:
-
Modify:
Music/ViewModels/PlayerViewModel.swift(setQueue,setProvider) -
Test:
MusicTests/PlayerViewModelTests.swift -
Step 1: Write the failing tests
Add to PlayerViewModelTests:
// Step 1: context [1,2,3], track 1 playing; queue tracks 4,5,6.
// Step 2: removeFromQueue removes the middle entry → [4,6].
// Step 3: moveInQueue moves the last entry to the front → [6,4].
@Test func removeAndMoveMutateManualQueue() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[3]); vm.addToQueue(tracks[4]); vm.addToQueue(tracks[5])
#expect(vm.manualQueue.map { $0.track.id } == [4, 5, 6])
vm.removeFromQueue(at: IndexSet(integer: 1))
#expect(vm.manualQueue.map { $0.track.id } == [4, 6])
vm.moveInQueue(from: IndexSet(integer: 1), to: 0)
#expect(vm.manualQueue.map { $0.track.id } == [6, 4])
}
// Step 1: context [1,2,3,4], track 2 playing (currentIndex 1).
// Step 2: upcomingContext is the slice after the current position → [3,4].
@Test func upcomingContextReturnsTracksAfterCurrent() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(4)
vm.setQueue(tracks)
vm.play(tracks[1])
#expect(vm.upcomingContext.map { $0.id } == [3, 4])
}
// Step 1: 10-track context, one playing; queue tracks 11 and 12 in order.
// Step 2: toggling shuffle reorders only the context — the manual queue order
// must be left exactly as the user arranged it.
@Test func shuffleLeavesManualQueueIntact() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(12)
vm.setQueue(Array(tracks[0..<10]))
vm.play(tracks[0])
vm.addToQueue(tracks[10]) // id 11
vm.addToQueue(tracks[11]) // id 12
vm.toggleShuffle()
#expect(vm.manualQueue.map { $0.track.id } == [11, 12])
}
// Step 1: setQueue accepts an optional context label for the panel header.
@Test func setQueueStoresContextName() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
vm.setQueue(makeTracks(2), contextName: "Synthwave")
#expect(vm.contextName == "Synthwave")
}
- Step 2: Run the tests to verify they fail
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests/setQueueStoresContextName 2>&1 | tail -30
Expected: FAIL to compile — setQueue has no contextName: parameter. (removeAndMoveMutateManualQueue, upcomingContext..., and shuffleLeaves... already pass from Task 1's methods, except the contextName compile error blocks the whole suite.)
- Step 3: Add the
contextNameparameter tosetQueue
In Music/ViewModels/PlayerViewModel.swift, replace the setQueue signature line:
func setQueue(_ tracks: [Track]) {
originalQueue = tracks
with:
func setQueue(_ tracks: [Track], contextName: String? = nil) {
self.contextName = contextName
originalQueue = tracks
(The default nil keeps existing callers and tests compiling.)
- Step 4: Reset the new state in
setProvider
In the setProvider(_:) method, add the two resets next to the existing queue = [] / originalQueue = [] lines (currently lines 55-56):
queue = []
originalQueue = []
manualQueue = []
contextName = nil
- Step 5: Run the tests to verify they pass
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30
Expected: PASS (all PlayerViewModel tests, old and new).
- Step 6: Commit (checkpoint — via
/commit)
git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift
# Suggested message: "feat: queue edit ops, upcomingContext, and contextName label"
Task 4: Context-menu config — onPlayNext / onAddToQueue + both menu builders
Files:
-
Modify:
Music/Models/TrackContextMenuConfig.swift -
Modify:
Music/Views/TrackContextMenuModifier.swift(SwiftUI menu) -
Modify:
Music/Views/TrackTableView.swift:328-394(AppKit menu + actions) -
Test:
MusicTests/TrackContextMenuConfigTests.swift -
Step 1: Write the failing test
The two new config fields get = nil defaults (Step 3), so the existing
TrackContextMenuConfig(...) constructions in this file and in ContentView
keep compiling untouched. Just add a new test to TrackContextMenuConfigTests:
// Verifies the queue callbacks fire with the right track.
@Test func queueCallbacksFire() {
let track = Track.fixture(id: 7, title: "Q")
var playNextTrack: Track? = nil
var addQueueTrack: Track? = nil
let config = TrackContextMenuConfig(
playlists: [],
lastUsedPlaylistName: nil,
selectedPlaylist: nil,
onAddToPlaylist: { _, _ in },
onAddToLastPlaylist: nil,
onRemoveFromPlaylist: nil,
onPlayNext: { t in playNextTrack = t },
onAddToQueue: { t in addQueueTrack = t }
)
config.onPlayNext?(track)
config.onAddToQueue?(track)
#expect(playNextTrack?.id == 7)
#expect(addQueueTrack?.id == 7)
}
- Step 2: Run the test to verify it fails
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests 2>&1 | tail -30
Expected: FAIL to compile — extra arguments 'onPlayNext', 'onAddToQueue' (the struct has no such members yet).
- Step 3: Add the closures to the config struct
In Music/Models/TrackContextMenuConfig.swift, add the two fields after onRemoveFromPlaylist. The = nil defaults flow into the synthesized memberwise initializer, so existing callers (ContentView + the other config test) keep compiling and the items stay hidden until wired:
let onRemoveFromPlaylist: ((Track) -> Void)?
// nil hides the corresponding item (e.g. when driving a remote device).
let onPlayNext: ((Track) -> Void)? = nil
let onAddToQueue: ((Track) -> Void)? = nil
- Step 4: Run the config test to verify it passes
Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests 2>&1 | tail -30
Expected: PASS, and the app target still builds (existing callers use the nil defaults).
Note: the menu items are wired with real closures in Task 6. Until then
ContentViewpasses the defaultnil, so the items stay hidden — expected interim state.
- Step 5: Render the items in the SwiftUI menu
In Music/Views/TrackContextMenuModifier.swift, inside if let track, let config {, insert this block before the existing lastUsedPlaylistName block (so Play Next / Add to Queue appear at the top):
if let onPlayNext = config.onPlayNext {
Button("Play Next") { onPlayNext(track) }
}
if let onAddToQueue = config.onAddToQueue {
Button("Add to Queue") { onAddToQueue(track) }
}
if config.onPlayNext != nil || config.onAddToQueue != nil {
Divider()
}
- Step 6: Render the items in the AppKit menu
In Music/Views/TrackTableView.swift, in menuNeedsUpdate(_:), insert this block immediately after the two guard lines (after guard let config = parent.contextMenuConfig else { return }) and before the if let lastPlaylistName block:
if config.onPlayNext != nil {
let item = NSMenuItem(title: "Play Next", action: #selector(playNext(_:)), keyEquivalent: "")
item.target = self
menu.addItem(item)
}
if config.onAddToQueue != nil {
let item = NSMenuItem(title: "Add to Queue", action: #selector(addToQueue(_:)), keyEquivalent: "")
item.target = self
menu.addItem(item)
}
if config.onPlayNext != nil || config.onAddToQueue != nil {
menu.addItem(.separator())
}
Then add the two action handlers next to the existing addToLastPlaylist / removeFromPlaylist handlers (after removeFromPlaylist(_:)'s closing brace, currently line 394):
@objc func playNext(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
config.onPlayNext?(tracks[tableView.clickedRow])
}
@objc func addToQueue(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
config.onAddToQueue?(tracks[tableView.clickedRow])
}
- Step 7: Commit (checkpoint — via
/commit)
git add Music/Models/TrackContextMenuConfig.swift Music/Views/TrackContextMenuModifier.swift Music/Views/TrackTableView.swift MusicTests/TrackContextMenuConfigTests.swift
# Suggested message: "feat: add Play Next / Add to Queue context-menu items"
Task 5: QueueView panel + PlayerControlsView toggle button
Files:
- Create:
Music/Views/QueueView.swift - Modify:
Music/Views/PlayerControlsView.swift
This task is UI; it is verified by a clean build and (optionally) by running the app, not by unit tests.
- Step 1: Create
QueueView
Create Music/Views/QueueView.swift:
import SwiftUI
// The right-docked "Up Next" panel. The manual "Queue" section is reorderable and
// removable; the "Next from" section is the read-only upcoming context (double-click
// a row to jump to it).
struct QueueView: View {
var player: PlayerViewModel
var body: some View {
List {
if player.manualQueue.isEmpty && player.upcomingContext.isEmpty {
Text("Queue is empty.\nRight-click a track → Add to Queue.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
.listRowSeparator(.hidden)
}
if !player.manualQueue.isEmpty {
Section("Queue") {
ForEach(player.manualQueue) { entry in
HStack(spacing: 8) {
trackRow(entry.track)
Spacer()
Button {
if let idx = player.manualQueue.firstIndex(where: { $0.id == entry.id }) {
player.removeFromQueue(at: IndexSet(integer: idx))
}
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.onMove(perform: player.moveInQueue)
}
}
if !player.upcomingContext.isEmpty {
Section("Next from: \(player.contextName ?? "Library")") {
ForEach(Array(player.upcomingContext.enumerated()), id: \.offset) { _, track in
trackRow(track)
.contentShape(Rectangle())
.onTapGesture(count: 2) { player.play(track) }
}
}
}
}
.listStyle(.inset)
.frame(width: 280)
}
private func trackRow(_ track: Track) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
Text(track.artist)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
- Step 2: Add toggle inputs to
PlayerControlsView
In Music/Views/PlayerControlsView.swift, add three properties immediately after var contextMenuConfig: TrackContextMenuConfig? = nil (line 23):
var isQueueVisible: Bool = false
var showQueueButton: Bool = true
var onToggleQueue: (() -> Void)? = nil
- Step 3: Render the queue button
In the same file, replace the start of volumeSection:
private var volumeSection: some View {
HStack(spacing: 8) {
Image(systemName: volumeIconName)
with:
private var volumeSection: some View {
HStack(spacing: 8) {
if showQueueButton {
Button(action: { onToggleQueue?() }) {
Image(systemName: "list.bullet")
.font(.system(size: 13))
.foregroundStyle(isQueueVisible ? .blue : .secondary)
}
.buttonStyle(.plain)
}
Image(systemName: volumeIconName)
- Step 4: Build to verify it compiles
Run: xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20
Expected: ** BUILD SUCCEEDED ** (the new toggle props are unused until Task 6 wires them — defaults keep ContentView compiling).
- Step 5: Commit (checkpoint — via
/commit)
git add Music/Views/QueueView.swift Music/Views/PlayerControlsView.swift
# Suggested message: "feat: add Up Next QueueView panel and transport queue toggle"
Task 6: Wire everything into ContentView
Files:
- Modify:
Music/ContentView.swift
UI integration — verified by a full build and the complete test suite.
- Step 1: Add panel visibility state
In Music/ContentView.swift, add to the @State block (e.g. after @State private var showHome = false, line 24):
@State private var showQueue = false
- Step 2: Wire the queue closures into the context-menu config
Replace the whole trackContextMenuConfig computed property (lines 358-377) with:
private var trackContextMenuConfig: TrackContextMenuConfig {
// Queue actions are local-only for v1: hidden when driving a remote device.
let queueEnabled = !(networkStatus?.isRemoteMode ?? false)
return TrackContextMenuConfig(
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
// Outer nil hides the "Remove from Playlist" menu item when not in a playlist view.
// Inner re-check defends against the playlist being deselected between menu display and action.
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
onPlayNext: queueEnabled ? { track in player.playNext(track) } : nil,
onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil
)
}
- Step 3: Dock the panel beside the main content
In body, wrap the main-content region in an HStack and append the panel. Replace the opening of that region (currently lines 118-119):
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
with:
HStack(spacing: 0) {
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
Then replace its closing .frame(maxHeight: .infinity) (currently line 191) with:
}
.frame(maxHeight: .infinity)
if showQueue {
Divider()
QueueView(player: player)
}
}
(The first } closes the existing inner VStack; the new outer } closes the added HStack.)
- Step 4: Pass context labels at every
setQueuecall site
Make these four edits in ContentView.swift:
- HomeView
onTrackDoubleClick(line 155):player.setQueue(recentTracks)→player.setQueue(recentTracks, contextName: "Recently Added") - TrackTableView
onDoubleClick(line 178):player.setQueue(trackList)→player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library") onPlayPauseempty-state (line 393):player.setQueue(trackList)→player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")- Keyboard space handler (line 426):
player.setQueue(trackList)→player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")
- Step 5: Pass toggle props to
PlayerControlsView
In the playerControls computed property, add these arguments after onNowPlayingTap: and before contextMenuConfig: (line 408-409):
onNowPlayingTap: { scrollToPlayingTrigger = UUID() },
isQueueVisible: showQueue,
showQueueButton: !(networkStatus?.isRemoteMode ?? false),
onToggleQueue: { showQueue.toggle() },
contextMenuConfig: trackContextMenuConfig
- Step 6: Build to verify it compiles
Run: xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20
Expected: ** BUILD SUCCEEDED **.
- Step 7: Run the full test suite
Run: xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | tail -30
Expected: all tests pass, including the new queue tests and all pre-existing suites.
- Step 8: Manual verification (optional but recommended)
Use the /run or /verify skill to launch the app and confirm:
-
Right-click a track → "Play Next" and "Add to Queue" appear and work.
-
The transport
list.bulletbutton toggles the right panel. -
Queued tracks show under "Queue", reorder by drag, and remove via the × button.
-
Playing a queued track removes it from the panel; after the queue drains, the original playlist resumes at the right spot.
-
Step 9: Commit (checkpoint — via
/commit)
git add Music/ContentView.swift
# Suggested message: "feat: wire Up Next panel, queue toggle, and queue actions into ContentView"
Self-Review Notes (for the implementer)
- Backward compatibility:
queue/currentIndexkeep meaning the context; every pre-existingPlayerViewModelTestscase must stay green at each task. If one breaks, the new logic touched the context path incorrectly. - Remote gate: queue methods early-return when
remoteProvider != nil, andContentViewpassesnilqueue closures + hides the button whennetworkStatus.isRemoteMode. Streaming-client mode is not gated (it plays locally). - Duplicates:
QueueEntry.id(UUID) is the SwiftUI identity, so the same track can be queued multiple times without row glitches; removal looks up the entry byid, never by track.