diff --git a/.opencode/plans/queue-click-to-play.md b/.opencode/plans/queue-click-to-play.md
new file mode 100644
index 0000000..1f4888a
--- /dev/null
+++ b/.opencode/plans/queue-click-to-play.md
@@ -0,0 +1,169 @@
+# 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.
+
+```go
+ RequestAddTracksToQueue = "RequestAddTracksToQueue"
+ RequestPlayTracksNext = "RequestPlayTracksNext"
+ RequestPlayQueueIndex = "RequestPlayQueueIndex"
+```
+
+### 2. `frontend/src/events.ts` — Add matching TypeScript event constant
+Add `RequestPlayQueueIndex: "RequestPlayQueueIndex"` to the Events object.
+
+```typescript
+ 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):
+
+```go
+ 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):
+
+```go
+// 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):
+
+```go
+// 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):
+
+```typescript
+export interface QueueTrack {
+ id: number;
+ audioFileId: number;
+ filePath: string;
+ position: number;
+ title: string;
+ artist: string;
+}
+```
+
+**b) Add `playAtIndex` action** (after `cycleRepeat()`, around line 108):
+
+```typescript
+ playAtIndex(index: number): void {
+ EventsEmit(Events.RequestPlayQueueIndex, index);
+ }
+```
+
+### 5. `frontend/src/store/controllers/queue-controller.ts` — Expose `playAtIndex`
+
+Add after `cycleRepeat()` (around line 112):
+
+```typescript
+ 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):
+
+```typescript
+ private handleTrackClick(index: number) {
+ this.queue.playAtIndex(index);
+ }
+```
+
+**b) Update the `
` element** to add a click handler and change cursor style. Update the `track-item` CSS from `cursor: default` to `cursor: pointer`:
+
+```css
+ .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 ``** and **stop propagation on the remove button** so clicking remove doesn't also trigger playback:
+
+```html
+ this.handleTrackClick(index)}>
+ ${index + 1}
+
+ ${this.getDisplayTitle(track)}
+ ${track.artist || 'Unknown Artist'}
+
+
+
+```
+
+## 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
diff --git a/AGENTS.md b/AGENTS.md
index 6db5a96..280d324 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,6 +56,65 @@ golangci-lint run --build-tags webkit2_41 ./... # With build tags expl
Frontend type checking: `cd frontend && pnpm exec tsc --noEmit`
+### Avoiding Common Linting Errors
+
+Always run `make lint` before considering a task complete. Below are the most common linting violations and how to avoid them.
+
+**Line length (`golines`)**: Keep lines under 100 characters. Break long function calls, especially `slog` calls, across multiple lines:
+```go
+// Bad — over 100 characters:
+q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks))
+
+// Good — broken across lines:
+q.logger.Warn(
+ "Current index out of range",
+ "index", q.currentIndex, "trackCount", len(q.tracks),
+)
+```
+
+**Stuttering type names (`revive`)**: Exported types must not repeat the package name. Consumers would write `queue.Track`, not `queue.QueueTrack`:
+```go
+// Bad — stutters as queue.QueueTrack:
+type QueueTrack struct { ... }
+
+// Good:
+type Track struct { ... }
+```
+
+**Cuddled declarations (`wsl`)**: `var` and `const` declarations must be separated from the preceding statement by a blank line:
+```go
+// Bad:
+wasEmpty := len(q.tracks) == 0
+var newTracks []Track
+
+// Good:
+wasEmpty := len(q.tracks) == 0
+
+var newTracks []Track
+```
+
+**Blank line after early returns (`nlreturn`)**: An `if` block that ends with `return`, `continue`, or `break` must be followed by a blank line:
+```go
+if err != nil {
+ return err
+}
+
+doNextThing()
+```
+
+**Error sentinels (`err113`)**: Never use `errors.New(...)` or `fmt.Errorf("...")` inline in return statements. Define package-level sentinel errors instead:
+```go
+var errNotFound = errors.New("not found")
+```
+
+**Doc comments (`godot`)**: All doc comments on exported types and functions must end with a period:
+```go
+// Track represents a track in the queue with its metadata.
+type Track struct { ... }
+```
+
+**Import order (`gci`)**: Three groups separated by blank lines — stdlib, third-party, internal (`yellowjacket/...`). Let the formatter handle this, but be aware of the expected grouping.
+
## Code Generation
`go:generate` directives live in `backend/app.go` (templ) and `backend/database/database.go` (sqlc). After modifying `.templ` files or SQL in `backend/database/sql/`, run `make generate`. **Never edit files in `backend/database/sql/sqlcgen/` or `*_templ.go` — they are generated.**
diff --git a/backend/config/httphandler.go b/backend/config/httphandler.go
index 3e6ab7b..8aa509f 100644
--- a/backend/config/httphandler.go
+++ b/backend/config/httphandler.go
@@ -29,7 +29,8 @@ func (c *Config) handle(w http.ResponseWriter, r *http.Request) {
if err := c.handleConfigPost(r); err != nil {
c.logger.Error("problem handling config post request", "err", err.Error())
- if renderErr := c.formSubmitError(err.Error()).Render(r.Context(), w); renderErr != nil {
+ renderErr := c.formSubmitError(err.Error()).Render(r.Context(), w)
+ if renderErr != nil {
c.logger.Error("problem rendering error response", "err", renderErr.Error())
}
diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go
index 0e1380c..45f5d34 100644
--- a/backend/database/sql/sqlcgen/playlists.sql.go
+++ b/backend/database/sql/sqlcgen/playlists.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: playlists.sql
package sqlcgen
diff --git a/backend/database/sql/sqlcgen/queue.sql.go b/backend/database/sql/sqlcgen/queue.sql.go
index e718b34..ff4bb0e 100644
--- a/backend/database/sql/sqlcgen/queue.sql.go
+++ b/backend/database/sql/sqlcgen/queue.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.29.0
+// sqlc v1.30.0
// source: queue.sql
package sqlcgen
diff --git a/backend/events/events.go b/backend/events/events.go
index 3c6d7b5..3e75157 100644
--- a/backend/events/events.go
+++ b/backend/events/events.go
@@ -42,6 +42,7 @@ const (
RequestCycleRepeat = "RequestCycleRepeat"
RequestAddTracksToQueue = "RequestAddTracksToQueue"
RequestPlayTracksNext = "RequestPlayTracksNext"
+ RequestPlayQueueIndex = "RequestPlayQueueIndex"
)
// Config events.
diff --git a/backend/library/coverart.go b/backend/library/coverart.go
index 86bfed6..6ba9e94 100644
--- a/backend/library/coverart.go
+++ b/backend/library/coverart.go
@@ -191,7 +191,10 @@ func (l *Library) generateMissingThumbnails() error {
imgData, err := os.ReadFile(filepath.Join(coverDir, name))
if err != nil {
- l.logger.Warn("could not read cover art for thumbnail generation", "file", name, "err", err)
+ l.logger.Warn(
+ "could not read cover art for thumbnail generation",
+ "file", name, "err", err,
+ )
continue
}
diff --git a/backend/player/player.go b/backend/player/player.go
index 4e2682d..23d120b 100644
--- a/backend/player/player.go
+++ b/backend/player/player.go
@@ -292,9 +292,11 @@ func (p *Player) startPaused() {
p.emitPlaybackFinished()
p.logger.Info("Playback finished naturally")
- // Notify queue for auto-advance.
+ // Notify queue for auto-advance. This must be dispatched to a new
+ // goroutine because beep.Callback runs with the speaker mutex held
+ // and the handler will call LoadFile/Play which acquire that same lock.
if p.playbackFinishedHandler != nil {
- p.playbackFinishedHandler()
+ go p.playbackFinishedHandler()
}
})))
@@ -432,6 +434,43 @@ func (p *Player) Pause() error {
return nil
}
+// UnloadTrack tears down the current track, releasing the file and streamer
+// chain. The player returns to the initial "no track loaded" state and emits
+// events so the frontend clears its current-track display.
+func (p *Player) UnloadTrack() {
+ // Stop audio output.
+ if p.control != nil {
+ speaker.Lock()
+ p.control.Paused = true
+ speaker.Unlock()
+ }
+
+ // Close the open audio file.
+ if p.currentFile != nil {
+ if err := p.currentFile.Close(); err != nil {
+ p.logger.Warn("Failed to close audio file during unload", "err", err)
+ }
+
+ p.currentFile = nil
+ }
+
+ // Release streamer chain. Volume is intentionally kept so the user's
+ // volume setting persists across tracks.
+ p.baseStreamer = nil
+ p.seeker = nil
+ p.resampled = nil
+ p.control = nil
+ p.speakerStreamer = nil
+
+ p.state = Stopped
+
+ // Notify frontend that there is no longer a current track.
+ p.emitPlaybackStateChanged(p.state)
+ runtime.EventsEmit(p.ctx, events.TrackChanged, nil)
+
+ p.logger.Info("Track unloaded")
+}
+
// SetVolume sets the playback volume (0-100).
func (p *Player) SetVolume(desiredVolume UserVolume) error {
speaker.Lock()
@@ -571,7 +610,10 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
coverArtThumbnail = "/covers/" + name + "_thumb.jpg"
}
} else {
- p.logger.Debug("Could not get track metadata from database", "path", filePath, "err", err)
+ p.logger.Debug(
+ "Could not get track metadata from database",
+ "path", filePath, "err", err,
+ )
}
}
diff --git a/backend/player/volume.go b/backend/player/volume.go
index 80c271c..b1d4ecd 100644
--- a/backend/player/volume.go
+++ b/backend/player/volume.go
@@ -14,7 +14,7 @@ const (
// Internal volume range bounds.
const (
- MinVol Volume = -4
+ MinVol Volume = -6
MaxVol Volume = 0
)
diff --git a/backend/queue/queue.go b/backend/queue/queue.go
index 58e996a..28edfe7 100644
--- a/backend/queue/queue.go
+++ b/backend/queue/queue.go
@@ -36,10 +36,11 @@ type TrackLoader interface {
LoadFile(filePath string) error
Play() error
CurrentPositionSeconds() (int, error)
+ UnloadTrack()
}
-// QueueTrack represents a track in the queue with its metadata.
-type QueueTrack struct {
+// Track represents a track in the queue with its metadata.
+type Track struct {
ID int64 `json:"id"`
AudioFileID int64 `json:"audioFileId"`
FilePath string `json:"filePath"`
@@ -48,13 +49,13 @@ type QueueTrack struct {
Artist string `json:"artist"`
}
-// QueueState is the full state emitted to the frontend.
-type QueueState struct {
- Tracks []QueueTrack `json:"tracks"`
- CurrentIndex int `json:"currentIndex"`
- ShuffleMode bool `json:"shuffleMode"`
- RepeatMode RepeatMode `json:"repeatMode"`
- SourcePlaylistID int64 `json:"sourcePlaylistId"`
+// State is the full state emitted to the frontend.
+type State struct {
+ Tracks []Track `json:"tracks"`
+ CurrentIndex int `json:"currentIndex"`
+ ShuffleMode bool `json:"shuffleMode"`
+ RepeatMode RepeatMode `json:"repeatMode"`
+ SourcePlaylistID int64 `json:"sourcePlaylistId"`
}
// Queue manages an ordered list of tracks for playback.
@@ -65,7 +66,7 @@ type Queue struct {
player TrackLoader
mu sync.Mutex
- tracks []QueueTrack
+ tracks []Track
currentIndex int
shuffleMode bool
repeatMode RepeatMode
@@ -106,6 +107,7 @@ func (q *Queue) OnPlaybackFinished() {
// Repeat One: replay the current track.
if q.repeatMode == RepeatOne {
q.playCurrentTrack()
+ q.emitQueueChanged()
return
}
@@ -120,6 +122,7 @@ func (q *Queue) OnPlaybackFinished() {
q.currentIndex = nextIdx
q.playCurrentTrack()
+ q.emitQueueChanged()
}
// registerEventHandlers sets up Wails event listeners for queue commands.
@@ -179,6 +182,11 @@ func (q *Queue) registerEventHandlers() {
q.logger.Info("Received RequestPlayTracksNext")
q.handlePlayTracksNext(data...)
})
+
+ runtime.EventsOn(q.ctx, events.RequestPlayQueueIndex, func(data ...any) {
+ q.logger.Info("Received RequestPlayQueueIndex")
+ q.handlePlayQueueIndex(data...)
+ })
}
// handleSetQueue processes the RequestSetQueue event payload.
@@ -298,6 +306,25 @@ func (q *Queue) handleAddTracksToQueue(data ...any) {
q.AddTracks(filePaths)
}
+// 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))
+}
+
// handlePlayTracksNext processes the RequestPlayTracksNext event payload.
// Expects data[0] = []interface{} of file path strings.
func (q *Queue) handlePlayTracksNext(data ...any) {
@@ -331,7 +358,7 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
defer q.mu.Unlock()
// Look up audio file IDs and metadata for all paths.
- tracks := make([]QueueTrack, 0, len(filePaths))
+ tracks := make([]Track, 0, len(filePaths))
for i, fp := range filePaths {
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
@@ -341,7 +368,7 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
continue
}
- track := QueueTrack{
+ track := Track{
AudioFileID: af.ID,
FilePath: fp,
Position: int64(i),
@@ -395,7 +422,7 @@ func (q *Queue) AddTrack(filePath string) {
wasEmpty := len(q.tracks) == 0
- track := QueueTrack{
+ track := Track{
AudioFileID: af.ID,
FilePath: filePath,
Position: int64(len(q.tracks)),
@@ -449,7 +476,7 @@ func (q *Queue) AddTracks(filePaths []string) {
continue
}
- track := QueueTrack{
+ track := Track{
AudioFileID: af.ID,
FilePath: fp,
Position: int64(len(q.tracks)),
@@ -490,7 +517,8 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
}
wasEmpty := len(q.tracks) == 0
- var newTracks []QueueTrack
+
+ var newTracks []Track
for _, fp := range filePaths {
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
@@ -500,7 +528,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
continue
}
- track := QueueTrack{
+ track := Track{
AudioFileID: af.ID,
FilePath: fp,
}
@@ -519,7 +547,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
}
// Insert the block into the slice at insertPos.
- tail := make([]QueueTrack, len(q.tracks[insertPos:]))
+ tail := make([]Track, len(q.tracks[insertPos:]))
copy(tail, q.tracks[insertPos:])
q.tracks = append(q.tracks[:insertPos], newTracks...)
q.tracks = append(q.tracks, tail...)
@@ -558,7 +586,7 @@ func (q *Queue) InsertNext(filePath string) {
insertPos = len(q.tracks)
}
- track := QueueTrack{
+ track := Track{
AudioFileID: af.ID,
FilePath: filePath,
Position: int64(insertPos),
@@ -572,7 +600,7 @@ func (q *Queue) InsertNext(filePath string) {
}
// Insert into slice.
- q.tracks = append(q.tracks, QueueTrack{})
+ q.tracks = append(q.tracks, Track{})
copy(q.tracks[insertPos+1:], q.tracks[insertPos:])
q.tracks[insertPos] = track
@@ -601,8 +629,9 @@ func (q *Queue) RemoveTrack(position int) {
q.tracks = append(q.tracks[:position], q.tracks[position+1:]...)
- // Adjust current index if needed.
- if position < q.currentIndex {
+ // Adjust current index if needed. A currentIndex of -1 means no track
+ // is loaded, so only shift when a valid track is selected.
+ if q.currentIndex >= 0 && position < q.currentIndex {
q.currentIndex--
} else if position == q.currentIndex && q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
q.currentIndex = len(q.tracks) - 1
@@ -645,7 +674,7 @@ func (q *Queue) Previous() {
q.mu.Lock()
defer q.mu.Unlock()
- if len(q.tracks) == 0 {
+ if len(q.tracks) == 0 || q.currentIndex < 0 {
return
}
@@ -674,6 +703,26 @@ func (q *Queue) Previous() {
q.emitQueueChanged()
}
+// 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()
+}
+
// ToggleShuffle toggles shuffle mode on/off.
func (q *Queue) ToggleShuffle() {
q.mu.Lock()
@@ -710,14 +759,14 @@ func (q *Queue) CycleRepeat() {
}
// GetState returns the current queue state for the frontend.
-func (q *Queue) GetState() QueueState {
+func (q *Queue) GetState() State {
q.mu.Lock()
defer q.mu.Unlock()
- tracks := make([]QueueTrack, len(q.tracks))
+ tracks := make([]Track, len(q.tracks))
copy(tracks, q.tracks)
- return QueueState{
+ return State{
Tracks: tracks,
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
@@ -790,10 +839,10 @@ func (q *Queue) RestoreState() {
return
}
- q.tracks = make([]QueueTrack, 0, len(rows))
+ q.tracks = make([]Track, 0, len(rows))
for _, row := range rows {
- q.tracks = append(q.tracks, QueueTrack{
+ q.tracks = append(q.tracks, Track{
ID: row.ID,
AudioFileID: row.AudioFileID,
FilePath: row.FilePath,
@@ -803,7 +852,9 @@ func (q *Queue) RestoreState() {
})
}
- // Clamp current index.
+ // Clamp current index. A value of -1 is valid and means "no current
+ // track" (e.g. the queue was exhausted before shutdown). Only clamp
+ // when the index exceeds the restored track count.
if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
q.currentIndex = len(q.tracks) - 1
}
@@ -954,13 +1005,19 @@ func (q *Queue) playCurrentTrack() {
}
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
- q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks))
+ q.logger.Warn(
+ "Current index out of range",
+ "index", q.currentIndex, "trackCount", len(q.tracks),
+ )
return
}
track := q.tracks[q.currentIndex]
- q.logger.Info("Playing track from queue", "filePath", track.FilePath, "position", q.currentIndex)
+ q.logger.Info(
+ "Playing track from queue",
+ "filePath", track.FilePath, "position", q.currentIndex,
+ )
err := q.player.LoadFile(track.FilePath)
if err != nil {
@@ -978,10 +1035,19 @@ func (q *Queue) playCurrentTrack() {
}
// onQueueExhausted is called when there are no more tracks to play.
-// This is the extension point for a future fallback playlist feature.
+// It unloads the current track, resets the index to -1 (no current track),
+// and notifies the frontend.
func (q *Queue) onQueueExhausted() {
- q.logger.Info("Queue exhausted, stopping playback")
- // Future: load fallback playlist here.
+ q.logger.Info("Queue exhausted, unloading track")
+
+ q.currentIndex = -1
+
+ if q.player != nil {
+ q.player.UnloadTrack()
+ }
+
+ q.emitQueueChanged()
+ q.persistState()
}
// reindexPositions updates the Position field of all tracks to match slice index.
@@ -1047,7 +1113,7 @@ func (q *Queue) emitQueueChanged() {
return
}
- state := QueueState{
+ state := State{
Tracks: q.tracks,
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
@@ -1057,7 +1123,7 @@ func (q *Queue) emitQueueChanged() {
// Ensure tracks is never nil in JSON.
if state.Tracks == nil {
- state.Tracks = []QueueTrack{}
+ state.Tracks = []Track{}
}
runtime.EventsEmit(q.ctx, events.QueueChanged, state)
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index c04dddc..b7e89aa 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-02c7eb24a50fc8301be7488868be5860
\ No newline at end of file
+74e25cdcdccb20fc50b40dc29ec5f6f9
\ No newline at end of file
diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts
index 8f78bcb..55dd489 100644
--- a/frontend/src/components/queue-panel/queue-panel.ts
+++ b/frontend/src/components/queue-panel/queue-panel.ts
@@ -75,7 +75,7 @@ export class QueuePanel extends LitElement {
padding: 8px 16px;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
- cursor: default;
+ cursor: pointer;
}
.track-item:hover {
@@ -165,10 +165,15 @@ export class QueuePanel extends LitElement {
this.dispatchEvent(new CustomEvent('queue-panel-close', { bubbles: true, composed: true }));
}
- private handleRemoveTrack(position: number) {
+ private handleRemoveTrack(e: Event, position: number) {
+ e.stopPropagation();
this.queue.removeFromQueue(position);
}
+ private handleTrackClick(index: number) {
+ this.queue.playAtIndex(index);
+ }
+
private getDisplayTitle(track: { title: string; filePath: string }): string {
if (track.title) return track.title;
@@ -203,7 +208,8 @@ export class QueuePanel extends LitElement {