Files
yellowjacket/.planning/quick/14-fix-queue-player-desync-after-hot-reload/14-PLAN.md
T

8.4 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
quick-14 14 execute 1
backend/queue/queue.go
backend/queue/handlers.go
true
truths artifacts key_links
Next/Previous/OnPlaybackFinished do not emit QueueIndexChanged if the track fails to load
playOrLoadCurrentTrack returns a bool indicating success
playCurrentTrack returns a bool indicating success
On load failure, currentIndex is rolled back to its previous value
path provides
backend/queue/queue.go Roll-back-on-failure pattern for Next, Previous, Play, RepeatOne, and handleCurrentTrackRemoved
path provides
backend/queue/handlers.go Roll-back-on-failure pattern for OnPlaybackFinished
from to via pattern
playOrLoadCurrentTrack loadCurrentTrack / playCurrentTrack bool return value propagation if !q.playOrLoadCurrentTrack
Fix the queue/player desync that occurs when Next/Previous is called and the track fails to load into the player. Currently, `Next()`, `Previous()`, `OnPlaybackFinished()`, and related methods unconditionally advance `currentIndex` and emit `QueueIndexChanged` even when `loadCurrentTrack()` or `playCurrentTrack()` fails. This causes the queue panel to highlight a different track than what the player actually has loaded.

Purpose: Ensure the queue index always reflects the track the player actually has loaded. If a track load fails, roll back the index to its previous value and do not emit QueueIndexChanged.

Output: Patched queue.go and handlers.go with roll-back-on-failure semantics.

<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.planning/STATE.md @backend/queue/queue.go @backend/queue/handlers.go @backend/queue/navigation.go

From backend/queue/queue.go (lines 1115-1182):

// Currently returns nothing — needs to return bool
func (q *Queue) playOrLoadCurrentTrack(autoPlay bool) {
    if autoPlay {
        q.playCurrentTrack()
    } else {
        q.loadCurrentTrack()
    }
}

// Already returns bool
func (q *Queue) loadCurrentTrack() bool { ... }

// Currently returns nothing — needs to return bool
func (q *Queue) playCurrentTrack() { ... }

From backend/queue/queue.go (lines 871-945):

// Next() — unconditionally emits after advancing index
func (q *Queue) Next() {
    q.currentIndex = nextIdx
    q.playOrLoadCurrentTrack(wasPlaying)  // return value discarded
    q.emitIndexChanged()                  // always fires
}

// Previous() — same pattern, multiple paths
func (q *Queue) Previous() {
    // ... restart paths also call playOrLoadCurrentTrack without checking
    q.currentIndex = prevIdx
    q.playOrLoadCurrentTrack(wasPlaying)
    q.emitIndexChanged()
}

From backend/queue/handlers.go (lines 1-33):

func (q *Queue) OnPlaybackFinished() {
    q.currentIndex = nextIdx
    q.playCurrentTrack()      // return value ignored (void)
    q.emitIndexChanged()      // always fires
}
Task 1: Make playOrLoadCurrentTrack and playCurrentTrack return bool backend/queue/queue.go Change `playOrLoadCurrentTrack` to return `bool`:
func (q *Queue) playOrLoadCurrentTrack(autoPlay bool) bool {
    if autoPlay {
        return q.playCurrentTrack()
    }
    return q.loadCurrentTrack()
}

Change playCurrentTrack to return bool:

func (q *Queue) playCurrentTrack() bool {
    if !q.loadCurrentTrack() {
        return false
    }
    err := q.player.Play()
    if err != nil {
        track := q.tracks[q.currentIndex]
        q.logger.Error(
            "Failed to play file from queue",
            "filePath", track.FilePath, "err", err,
        )
        return false
    }
    return true
}

Update the doc comment on playOrLoadCurrentTrack to document the bool return value (true = success, false = load failed). Update the doc comment on playCurrentTrack to document the bool return value.

Note: loadCurrentTrack already returns bool — no change needed there. go build ./backend/... Both functions return bool; the codebase compiles.

Task 2: Add roll-back-on-failure to Next, Previous, OnPlaybackFinished, and related call sites backend/queue/queue.go, backend/queue/handlers.go Apply the roll-back-on-failure pattern to every call site that advances `currentIndex` and then calls `playOrLoadCurrentTrack`/`playCurrentTrack`.

In Next() (queue.go ~line 871):

The main advance path (after the RepeatOne early return):

prevIndex := q.currentIndex
q.currentIndex = nextIdx
if !q.playOrLoadCurrentTrack(wasPlaying) {
    q.currentIndex = prevIndex
    return
}
q.emitIndexChanged()

For the RepeatOne path (replay current track), the index doesn't change so there's nothing to roll back, but we should still guard the emit:

if q.repeatMode == RepeatOne {
    if q.playOrLoadCurrentTrack(wasPlaying) {
        q.emitIndexChanged()
    }
    return
}

In Previous() (queue.go ~line 904):

Same pattern for every branch:

  1. RepeatOne path (~line 914-919): Guard the emit with the return value:
if q.repeatMode == RepeatOne {
    if q.playOrLoadCurrentTrack(wasPlaying) {
        q.emitIndexChanged()
    }
    return
}
  1. Restart-current-track path (>3 seconds, ~line 922-930): The index doesn't change here either, just guard the emit:
if q.player != nil {
    posSecs, err := q.player.CurrentPositionSeconds()
    if err == nil && posSecs > PreviousRestartThreshold {
        if q.playOrLoadCurrentTrack(wasPlaying) {
            q.emitIndexChanged()
        }
        return
    }
}
  1. Navigate-to-previous path (~line 933-onwards): Apply full roll-back:
prevIdx := q.previousIndex()
if prevIdx == -1 {
    // At the beginning — just restart the current track.
    if q.playOrLoadCurrentTrack(wasPlaying) {
        q.emitIndexChanged()
    }
    return
}

prevCurrentIndex := q.currentIndex
q.currentIndex = prevIdx
if !q.playOrLoadCurrentTrack(wasPlaying) {
    q.currentIndex = prevCurrentIndex
    return
}
q.emitIndexChanged()

In OnPlaybackFinished() (handlers.go):

Apply roll-back to the main advance path:

// RepeatOne path — index doesn't change, guard emit:
if q.repeatMode == RepeatOne {
    if q.playCurrentTrack() {
        q.emitIndexChanged()
    }
    return
}

nextIdx := q.nextIndex()
if nextIdx == -1 {
    q.onQueueExhausted()
    return
}

prevIndex := q.currentIndex
q.currentIndex = nextIdx
if !q.playCurrentTrack() {
    q.currentIndex = prevIndex
    return
}
q.emitIndexChanged()

In handleCurrentTrackRemoved() (queue.go ~line 1184): Check what this does and apply same pattern if it calls loadCurrentTrack.

IMPORTANT: Do NOT change loadCurrentTrack() or loadFileLocked() themselves — they already work correctly. Only change the call sites that consume their return values.

IMPORTANT: Preserve the mutex-protected setter pattern (lock → write → release → callbacks). The emitIndexChanged() calls already happen inside the lock, which is correct. Just make them conditional. go build ./backend/... && go test ./backend/queue/... -v -count=1 All Next/Previous/OnPlaybackFinished paths check the bool return from playOrLoadCurrentTrack/playCurrentTrack. On failure, currentIndex is rolled back (when it was changed) and QueueIndexChanged is NOT emitted. Tests pass.

go build ./backend/... go test ./backend/queue/... -v -count=1 go vet ./backend/queue/...

<success_criteria>

  • playOrLoadCurrentTrack returns bool propagated from loadCurrentTrack/playCurrentTrack
  • playCurrentTrack returns bool (true if load + play succeeded)
  • Next() rolls back currentIndex and skips emitIndexChanged on failure
  • Previous() rolls back currentIndex and skips emitIndexChanged on failure (all branches)
  • OnPlaybackFinished() rolls back currentIndex and skips emitIndexChanged on failure
  • All existing tests pass
  • Code compiles with no vet warnings </success_criteria>
After completion, create `.planning/quick/14-fix-queue-player-desync-after-hot-reload/14-SUMMARY.md`