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

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

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

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 (the next() 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 contextName parameter to setQueue

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

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

            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)

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.