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.
780 lines
30 KiB
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.
|
|
|