# 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: 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: " 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.