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/plans/2026-05-30-playing-queue.md

780 lines
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 `/commit` skill*. 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):
```swift
// 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 `QueueEntry` model**
Create `Music/Models/QueueEntry.swift`:
```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):
```swift
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:
```swift
// 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:
```bash
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` (the `next()` method)
- Test: `MusicTests/PlayerViewModelTests.swift`
- [ ] **Step 1: Write the failing tests**
Add to `PlayerViewModelTests`:
```swift
// 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:
```swift
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:
```swift
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`)**
```bash
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`:
```swift
// 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 `contextName` parameter to `setQueue`**
In `Music/ViewModels/PlayerViewModel.swift`, replace the `setQueue` signature line:
```swift
func setQueue(_ tracks: [Track]) {
originalQueue = tracks
```
with:
```swift
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):
```swift
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`)**
```bash
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`:
```swift
// 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:
```swift
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 `ContentView` passes the default `nil`, 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):
```swift
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:
```swift
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):
```swift
@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`)**
```bash
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`:
```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):
```swift
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`:
```swift
private var volumeSection: some View {
HStack(spacing: 8) {
Image(systemName: volumeIconName)
```
with:
```swift
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`)**
```bash
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):
```swift
@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:
```swift
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):
```swift
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
```
with:
```swift
HStack(spacing: 0) {
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
```
Then replace its closing `.frame(maxHeight: .infinity)` (currently line 191) with:
```swift
}
.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 `setQueue` call site**
Make these four edits in `ContentView.swift`:
1. HomeView `onTrackDoubleClick` (line 155): `player.setQueue(recentTracks)``player.setQueue(recentTracks, contextName: "Recently Added")`
2. TrackTableView `onDoubleClick` (line 178): `player.setQueue(trackList)``player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")`
3. `onPlayPause` empty-state (line 393): `player.setQueue(trackList)``player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")`
4. 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):
```swift
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.bullet` button 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`)**
```bash
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`/`currentIndex` keep meaning the *context*; every pre-existing `PlayerViewModelTests` case 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`, and `ContentView` passes `nil` queue closures + hides the button when `networkStatus.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 by `id`, never by track.