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 {