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`