From 95e1b32e8631b7ff27944134ee42debb534473a1 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 16 Feb 2026 02:18:45 -0500 Subject: [PATCH] view tracks in playlist view and play playlists --- backend/database/sql/queries/playlists.sql | 22 ++ backend/database/sql/sqlcgen/playlists.sql.go | 70 +++++ backend/playlist/playlist.go | 66 ++++ .../components/playlist-view/playlist-view.ts | 290 ++++++++++++++++-- .../src/components/track-info/track-info.ts | 213 +++++++++++++ frontend/wailsjs/go/models.ts | 28 ++ frontend/wailsjs/go/playlist/Service.d.ts | 2 + frontend/wailsjs/go/playlist/Service.js | 4 + 8 files changed, 672 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/track-info/track-info.ts diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql index 599d588..3a55d32 100644 --- a/backend/database/sql/queries/playlists.sql +++ b/backend/database/sql/queries/playlists.sql @@ -31,6 +31,28 @@ DELETE FROM playlist_tracks WHERE id = ?; -- name: ClearPlaylistTracks :exec DELETE FROM playlist_tracks WHERE playlist_id = ?; +-- name: GetPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.audio_file_id = af.id +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +WHERE pt.playlist_id = ? +ORDER BY pt.position; + -- name: GetNextPlaylistTrackPosition :one SELECT COALESCE(MAX(position), -1) + 1 AS next_position FROM playlist_tracks WHERE playlist_id = ?; diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index fe4e944..6588004 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -172,6 +172,76 @@ func (q *Queries) GetPlaylistTracks(ctx context.Context, playlistID int64) ([]Ge return items, nil } +const getPlaylistTracksWithMetadata = `-- name: GetPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.audio_file_id = af.id +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +WHERE pt.playlist_id = ? +ORDER BY pt.position +` + +type GetPlaylistTracksWithMetadataRow struct { + ID int64 + PlaylistID int64 + AudioFileID int64 + Position int64 + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string + CoverArtPath string +} + +func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID int64) ([]GetPlaylistTracksWithMetadataRow, error) { + rows, err := q.db.QueryContext(ctx, getPlaylistTracksWithMetadata, playlistID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPlaylistTracksWithMetadataRow + for rows.Next() { + var i GetPlaylistTracksWithMetadataRow + if err := rows.Scan( + &i.ID, + &i.PlaylistID, + &i.AudioFileID, + &i.Position, + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.Artist, + &i.Album, + &i.CoverArtPath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const removePlaylistTrack = `-- name: RemovePlaylistTrack :exec DELETE FROM playlist_tracks WHERE id = ? ` diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 6bb9136..4bbc06a 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -6,10 +6,13 @@ import ( "errors" "fmt" "log/slog" + "path/filepath" + "strconv" "strings" "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/library" ) var ( @@ -24,6 +27,19 @@ type Summary struct { Name string `json:"Name"` } +// Track represents a track within a playlist, including its metadata. +type Track struct { + ID int64 `json:"ID"` + Position int64 `json:"Position"` + FilePath string `json:"FilePath"` + Title string `json:"Title"` + Artist string `json:"Artist"` + Album string `json:"Album"` + CoverArtPath string `json:"CoverArtPath"` + CoverArtThumbnailPath string `json:"CoverArtThumbnailPath"` + Duration string `json:"Duration"` +} + // Service manages playlist operations. type Service struct { ctx context.Context @@ -67,6 +83,56 @@ func (s *Service) GetAllPlaylists() ([]Summary, error) { return summaries, nil } +// GetPlaylistTracks returns all tracks in a playlist with full metadata. +func (s *Service) GetPlaylistTracks( + playlistID int64, +) ([]Track, error) { + rows, err := s.db.Queries.GetPlaylistTracksWithMetadata( + s.db.Ctx, + playlistID, + ) + if err != nil { + s.logger.Error( + "Failed to get playlist tracks", + "playlistId", playlistID, + "err", err, + ) + + return nil, fmt.Errorf( + "failed to get playlist tracks: %w", + err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + track := Track{ + ID: row.ID, + Position: row.Position, + FilePath: row.FilePath, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + Duration: strconv.FormatInt( + row.LengthMilliseconds, + 10, + ), + } + + if row.CoverArtPath != "" { + base := filepath.Base(row.CoverArtPath) + track.CoverArtPath = "/covers/" + base + track.CoverArtThumbnailPath = "/covers/" + + library.ThumbnailFilename(base) + } + + tracks = append(tracks, track) + } + + return tracks, nil +} + // CreatePlaylist creates a new empty playlist with the given name. func (s *Service) CreatePlaylist(name string) (Summary, error) { trimmed := strings.TrimSpace(name) diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index fd1042d..67711b0 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -1,4 +1,4 @@ -import { LitElement, html, css } from 'lit'; +import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; @@ -6,12 +6,24 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js'; import { GetAllPlaylists, CreatePlaylist, + GetPlaylistTracks, } from '@go/playlist/Service'; import type { playlist } from '@go/models'; +import { QueueController } from '@store/controllers/queue-controller'; +import '@components/track-info/track-info'; + +interface PlaylistEntry { + summary: playlist.Summary; + expanded: boolean; + loading: boolean; + tracks: playlist.Track[] | null; +} @customElement('playlist-view') export class PlaylistView extends LitElement { - @state() private playlists: playlist.Summary[] = []; + private queue = new QueueController(this); + + @state() private entries: PlaylistEntry[] = []; @state() private loading = true; @state() private creating = false; @state() private newPlaylistName = ''; @@ -126,17 +138,33 @@ export class PlaylistView extends LitElement { } .playlist-item { - display: flex; - align-items: center; - padding: 12px 16px; - gap: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } - .playlist-item:hover { + .playlist-header { + display: flex; + align-items: center; + padding: 12px 16px; + gap: 10px; + cursor: pointer; + user-select: none; + } + + .playlist-header:hover { background-color: rgba(255, 255, 255, 0.05); } + .chevron { + font-size: 14px; + color: #888; + flex-shrink: 0; + transition: transform 0.15s ease; + } + + .chevron.expanded { + transform: rotate(90deg); + } + .playlist-icon { font-size: 18px; color: #888; @@ -149,6 +177,64 @@ export class PlaylistView extends LitElement { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1; + } + + .track-count { + font-size: 11px; + color: #666; + flex-shrink: 0; + } + + .playlist-body { + padding: 0 16px 12px 42px; + } + + .playlist-actions { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 8px; + } + + .play-all-button { + background: none; + border: 1px solid #555; + border-radius: 4px; + color: #fff; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + font-family: inherit; + } + + .play-all-button:hover { + border-color: #ffd43b; + color: #ffd43b; + } + + .track-item { + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + } + + .track-item:last-child { + border-bottom: none; + } + + .tracks-loading { + padding: 12px 0; + color: #888; + font-size: 12px; + } + + .tracks-empty { + padding: 12px 0; + color: #666; + font-size: 12px; } .loading { @@ -187,16 +273,85 @@ export class PlaylistView extends LitElement { private async loadPlaylists() { try { this.loading = true; + const result = await GetAllPlaylists(); - this.playlists = result ?? []; + const summaries = result ?? []; + + this.entries = summaries.map((s) => ({ + summary: s, + expanded: false, + loading: false, + tracks: null, + })); } catch (err) { console.error('Failed to load playlists:', err); - this.playlists = []; + this.entries = []; } finally { this.loading = false; } } + private handleToggle = async (index: number) => { + const entry = this.entries[index]; + + if (!entry) return; + + // Collapse if already expanded. + if (entry.expanded) { + this.entries = this.entries.map((e, i) => + i === index ? { ...e, expanded: false } : e, + ); + + return; + } + + // Expand and lazy-load tracks if not yet fetched. + if (entry.tracks === null) { + this.entries = this.entries.map((e, i) => + i === index + ? { ...e, expanded: true, loading: true } + : e, + ); + + try { + const tracks = await GetPlaylistTracks( + entry.summary.ID, + ); + + this.entries = this.entries.map((e, i) => + i === index + ? { + ...e, + loading: false, + tracks: tracks ?? [], + } + : e, + ); + } catch (err) { + console.error('Failed to load playlist tracks:', err); + + this.entries = this.entries.map((e, i) => + i === index + ? { ...e, loading: false, tracks: [] } + : e, + ); + } + } else { + this.entries = this.entries.map((e, i) => + i === index ? { ...e, expanded: true } : e, + ); + } + }; + + private handlePlayAll = (index: number) => { + const entry = this.entries[index]; + + if (!entry?.tracks || entry.tracks.length === 0) return; + + const filePaths = entry.tracks.map((t) => t.FilePath); + this.queue.setQueue(filePaths, 0); + }; + private handleNewPlaylistClick = () => { this.creating = true; this.newPlaylistName = ''; @@ -256,9 +411,11 @@ export class PlaylistView extends LitElement { - ${this.creating ? this.renderCreateForm() : ''} + ${this.creating ? this.renderCreateForm() : nothing} ${this.loading - ? html`
Loading playlists...
` + ? html`
+ Loading playlists... +
` : this.renderPlaylistList()} `; } @@ -275,7 +432,9 @@ export class PlaylistView extends LitElement { @input=${this.handleInputChange} @keydown=${this.handleInputKeydown} /> - + + + ${entry.tracks.map( + (track) => html` +
+ +
+ `, + )} + + `; + } } declare global { diff --git a/frontend/src/components/track-info/track-info.ts b/frontend/src/components/track-info/track-info.ts new file mode 100644 index 0000000..73f7367 --- /dev/null +++ b/frontend/src/components/track-info/track-info.ts @@ -0,0 +1,213 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import '@awesome.me/webawesome/dist/components/icon/icon.js'; + +import { formatMilliseconds } from '@utils/time'; + +/** + * Reusable track info display component. + * + * All fields are optional — the parent decides which to provide. + * Handles text truncation, fallback display for missing title + * (uses filename from filePath), and a cover art placeholder. + * + * @example + * ```html + * + * + * + * ``` + */ +@customElement('track-info') +export class TrackInfo extends LitElement { + @property() trackTitle?: string; + @property() artist?: string; + @property() album?: string; + @property() coverArt?: string; + @property() coverArtThumbnail?: string; + @property() duration?: string; + @property() filePath?: string; + + static override styles = css` + :host { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + .cover-art { + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 3px; + overflow: hidden; + } + + .cover-art img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .cover-placeholder { + width: 100%; + height: 100%; + background-color: #2a2d30; + display: flex; + align-items: center; + justify-content: center; + } + + .cover-placeholder wa-icon { + color: #666; + font-size: 18px; + } + + .text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; + } + + .title { + font-size: 13px; + font-weight: 500; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .secondary { + font-size: 11px; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .duration { + font-size: 12px; + color: #888; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + } + `; + + override render() { + const showCover = + this.coverArt !== undefined || this.coverArtThumbnail !== undefined; + const displayTitle = this.getDisplayTitle(); + const secondaryParts = this.getSecondaryText(); + + return html` + ${showCover ? this.renderCoverArt() : nothing} +
+ ${displayTitle + ? html`${displayTitle}` + : nothing} + ${secondaryParts + ? html`${secondaryParts}` + : nothing} +
+ ${this.duration + ? html`${formatMilliseconds(this.duration)}` + : nothing} + `; + } + + private renderCoverArt() { + const src = this.coverArtThumbnail ?? this.coverArt; + + if (!src) { + return html` +
+
+ +
+
+ `; + } + + return html` +
+ Cover art +
+ `; + } + + private handleImageError = (e: Event) => { + const img = e.target as HTMLImageElement; + + // Try full-size image if thumbnail failed. + if (this.coverArt && img.src !== this.coverArt) { + img.src = this.coverArt; + + return; + } + + // Replace with placeholder on final failure. + const container = img.parentElement; + + if (container) { + container.innerHTML = + '
' + + '' + + '
'; + } + }; + + private getDisplayTitle(): string { + if (this.trackTitle) return this.trackTitle; + + if (this.filePath) { + const parts = this.filePath.split(/[\\/]/); + const filename = parts[parts.length - 1] ?? this.filePath; + + return filename.replace(/\.[^.]+$/, ''); + } + + return ''; + } + + private getSecondaryText(): string { + const parts: string[] = []; + + if (this.artist) { + parts.push(this.artist); + } + + if (this.album) { + parts.push(this.album); + } + + return parts.join(' \u2014 '); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'track-info': TrackInfo; + } +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bad5357..2cf94f5 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -59,6 +59,34 @@ export namespace playlist { this.Name = source["Name"]; } } + export class Track { + ID: number; + Position: number; + FilePath: string; + Title: string; + Artist: string; + Album: string; + CoverArtPath: string; + CoverArtThumbnailPath: string; + Duration: string; + + static createFrom(source: any = {}) { + return new Track(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ID = source["ID"]; + this.Position = source["Position"]; + this.FilePath = source["FilePath"]; + this.Title = source["Title"]; + this.Artist = source["Artist"]; + this.Album = source["Album"]; + this.CoverArtPath = source["CoverArtPath"]; + this.CoverArtThumbnailPath = source["CoverArtThumbnailPath"]; + this.Duration = source["Duration"]; + } + } } diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts index 5a6a21d..2d2840c 100755 --- a/frontend/wailsjs/go/playlist/Service.d.ts +++ b/frontend/wailsjs/go/playlist/Service.d.ts @@ -11,4 +11,6 @@ export function CreatePlaylistWithTracks(arg1:string,arg2:Array):Promise export function GetAllPlaylists():Promise>; +export function GetPlaylistTracks(arg1:number):Promise>; + export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js index e260d5a..e2b7450 100755 --- a/frontend/wailsjs/go/playlist/Service.js +++ b/frontend/wailsjs/go/playlist/Service.js @@ -18,6 +18,10 @@ export function GetAllPlaylists() { return window['go']['playlist']['Service']['GetAllPlaylists'](); } +export function GetPlaylistTracks(arg1) { + return window['go']['playlist']['Service']['GetPlaylistTracks'](arg1); +} + export function SetContext(arg1) { return window['go']['playlist']['Service']['SetContext'](arg1); }