Files
yellowjacket/.opencode/plans/queue-click-to-play.md
T

5.0 KiB

Plan: Queue Click-to-Play

Goal

When a track in the queue panel is clicked, that track should start playing.

Architecture Overview

The app uses a unidirectional event system: Frontend emits request events -> Backend processes them -> Backend emits state-changed events -> Frontend stores update -> Lit components re-render. The queue backend (backend/queue/queue.go) drives playback via playCurrentTrack() which calls player.LoadFile() then player.Play().

Changes Required (6 files)

1. backend/events/events.go — Add new event constant

Add RequestPlayQueueIndex = "RequestPlayQueueIndex" to the queue events const block.

	RequestAddTracksToQueue  = "RequestAddTracksToQueue"
	RequestPlayTracksNext   = "RequestPlayTracksNext"
	RequestPlayQueueIndex   = "RequestPlayQueueIndex"

2. frontend/src/events.ts — Add matching TypeScript event constant

Add RequestPlayQueueIndex: "RequestPlayQueueIndex" to the Events object.

    RequestAddTracksToQueue: "RequestAddTracksToQueue",
    RequestPlayTracksNext: "RequestPlayTracksNext",
    RequestPlayQueueIndex: "RequestPlayQueueIndex",

3. backend/queue/queue.go — Add PlayIndex method + event handler

a) Add event handler registration in registerEventHandlers(), after the RequestPlayTracksNext handler (around line 184):

	runtime.EventsOn(q.ctx, events.RequestPlayQueueIndex, func(data ...any) {
		q.logger.Info("Received RequestPlayQueueIndex")
		q.handlePlayQueueIndex(data...)
	})

b) Add handler function (after handlePlayTracksNext, around line 329):

// handlePlayQueueIndex processes the RequestPlayQueueIndex event payload.
// Expects data[0] = float64 index.
func (q *Queue) handlePlayQueueIndex(data ...any) {
	if len(data) < 1 {
		q.logger.Error("RequestPlayQueueIndex: missing data")

		return
	}

	index, ok := data[0].(float64)
	if !ok {
		q.logger.Error("RequestPlayQueueIndex: invalid index type", "got", data[0])

		return
	}

	q.PlayIndex(int(index))
}

c) Add PlayIndex method (after Previous(), around line 679):

// PlayIndex jumps to and plays the track at the given index.
func (q *Queue) PlayIndex(index int) {
	q.mu.Lock()
	defer q.mu.Unlock()

	if len(q.tracks) == 0 {
		return
	}

	if index < 0 || index >= len(q.tracks) {
		q.logger.Warn("PlayIndex: index out of range", "index", index, "trackCount", len(q.tracks))

		return
	}

	q.currentIndex = index
	q.playCurrentTrack()
	q.emitQueueChanged()
}

This is simple and consistent with how SetQueue works — it sets currentIndex directly and calls playCurrentTrack(). When shuffle is on, the current track changes but the shuffle order stays intact. Subsequent Next/Previous calls will navigate relative to the new position in the shuffle order.

4. frontend/src/store/queue-store.ts — Add playAtIndex + fix QueueTrack type

a) Fix QueueTrack interface (add title and artist fields that the backend sends):

export interface QueueTrack {
  id: number;
  audioFileId: number;
  filePath: string;
  position: number;
  title: string;
  artist: string;
}

b) Add playAtIndex action (after cycleRepeat(), around line 108):

  playAtIndex(index: number): void {
    EventsEmit(Events.RequestPlayQueueIndex, index);
  }

5. frontend/src/store/controllers/queue-controller.ts — Expose playAtIndex

Add after cycleRepeat() (around line 112):

  playAtIndex(index: number): void {
    queueStore.playAtIndex(index);
  }

6. frontend/src/components/queue-panel/queue-panel.ts — Add click handler

a) Add click handler method (after handleRemoveTrack, around line 170):

  private handleTrackClick(index: number) {
    this.queue.playAtIndex(index);
  }

b) Update the <li> element to add a click handler and change cursor style. Update the track-item CSS from cursor: default to cursor: pointer:

    .track-item {
      display: flex;
      align-items: center;
      padding: 8px 16px;
      gap: 12px;
      border-bottom: 1px solid rgba(255, 255, 255, 0.05);
      cursor: pointer;
    }

c) Add @click handler to the <li> and stop propagation on the remove button so clicking remove doesn't also trigger playback:

<li class="track-item ${index === currentIndex ? 'active' : ''}"
    @click=${() => this.handleTrackClick(index)}>
  <span class="track-position">${index + 1}</span>
  <div class="track-details">
    <span class="track-title">${this.getDisplayTitle(track)}</span>
    <span class="track-artist">${track.artist || 'Unknown Artist'}</span>
  </div>
  <button
    class="remove-button"
    @click=${(e: Event) => { e.stopPropagation(); this.handleRemoveTrack(index); }}
    title="Remove from queue"
  >
    <wa-icon name="xmark"></wa-icon>
  </button>
</li>

Verification

After making changes:

  1. make lint — Go linting passes
  2. make test — Go tests pass
  3. cd frontend && pnpm exec tsc --noEmit — TypeScript type checking passes