From 9a3f1e4a246056ad3c54e19d31e7851e88baac94 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 22:04:36 -0500 Subject: [PATCH] Make the now-playing bar's title/artist clickable and add a cover art lightbox The song title links to its album, the artist name links to the artist page, and clicking the cover art opens a larger version in a modal. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv --- internal/database/library.go | 15 +++++++++++++++ internal/web/views.go | 4 ++++ web/static/css/app.css | 14 ++++++++++++++ web/static/js/app.js | 18 ++++++++++++++++++ web/templates/partials/cards.html | 2 ++ web/templates/partials/footer.html | 19 +++++++++++++++---- 6 files changed, 68 insertions(+), 4 deletions(-) diff --git a/internal/database/library.go b/internal/database/library.go index 67731f7..4485ec5 100644 --- a/internal/database/library.go +++ b/internal/database/library.go @@ -492,6 +492,21 @@ func ListSongArtistNames(db *sql.DB, songID string) ([]string, error) { return out, rows.Err() } +// PrimarySongArtistID returns the ID of a song's first-credited artist +// (position 0), or "" if the song has no linked artist — used to link +// the now-playing bar's artist name to that artist's page. +func PrimarySongArtistID(db *sql.DB, songID string) (string, error) { + var id string + err := db.QueryRow(`SELECT a.id FROM song_artists sa JOIN artists a ON a.id = sa.artist_id WHERE sa.song_id = ? ORDER BY sa.position LIMIT 1`, songID).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return id, nil +} + func nullInt(n sql.NullInt64) any { if !n.Valid { return nil diff --git a/internal/web/views.go b/internal/web/views.go index 03a9187..4d1899e 100644 --- a/internal/web/views.go +++ b/internal/web/views.go @@ -28,6 +28,7 @@ type SongRow struct { ID string Title string ArtistName string + ArtistID string AlbumTitle string AlbumID string CoverArtID string @@ -81,6 +82,9 @@ func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID strin row.ArtistName += ", " + n } } + if id, err := database.PrimarySongArtistID(db, s.ID); err == nil { + row.ArtistID = id + } if album != nil { row.AlbumTitle = album.Title row.AlbumID = album.ID diff --git a/web/static/css/app.css b/web/static/css/app.css index 85e06de..4a81e96 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -226,19 +226,33 @@ body { } .vs-player-title { + display: block; font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: inherit; + text-decoration: none; } .vs-player-artist { + display: block; font-size: 0.78rem; color: var(--bs-secondary-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + text-decoration: none; +} + +.vs-player-title:hover, +.vs-player-artist:hover { + text-decoration: underline; +} + +.vs-player-art[role="button"] { + cursor: pointer; } .vs-player-controls { diff --git a/web/static/js/app.js b/web/static/js/app.js index 7f531b8..15bd6bc 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -114,7 +114,9 @@ if (!song) return; this.audio.src = "/stream/" + encodeURIComponent(song.id); this.ui.title.textContent = song.title || ""; + this.ui.title.href = song.albumId ? "/albums/" + encodeURIComponent(song.albumId) : "javascript:void(0)"; this.ui.artist.textContent = song.artist || ""; + this.ui.artist.href = song.artistId ? "/artists/" + encodeURIComponent(song.artistId) : "javascript:void(0)"; this.ui.art.src = song.cover ? "/cover-art/" + encodeURIComponent(song.cover) + "?size=100" : "/static/img/default-art.svg"; this.ui.starBtn.dataset.itemType = "song"; this.ui.starBtn.dataset.itemId = song.id; @@ -307,7 +309,9 @@ id: node.dataset.songId, title: node.dataset.title, artist: node.dataset.artist, + artistId: node.dataset.artistId, album: node.dataset.album, + albumId: node.dataset.albumId, cover: node.dataset.cover, starred: node.dataset.starred === "1", }); @@ -348,6 +352,20 @@ }); } + var artModalEl = document.getElementById("vs-player-art-modal"); + if (ui.art && artModalEl && window.bootstrap) { + var artModalImg = document.getElementById("vs-player-art-modal-img"); + var artModal = new bootstrap.Modal(artModalEl); + ui.art.addEventListener("click", function () { + var song = player.queue[player.index]; + artModalImg.src = song && song.cover + ? "/cover-art/" + encodeURIComponent(song.cover) + "?size=1200" + : "/static/img/default-art.svg"; + artModalImg.alt = song ? (song.title || "") : ""; + artModal.show(); + }); + } + var lyricsBtn = document.getElementById("vs-lyrics-btn"); if (lyricsBtn && ui.lyricsPanel) { lyricsBtn.addEventListener("click", function () { diff --git a/web/templates/partials/cards.html b/web/templates/partials/cards.html index 64a5440..75593f3 100644 --- a/web/templates/partials/cards.html +++ b/web/templates/partials/cards.html @@ -22,7 +22,9 @@ data-song-id="{{$s.ID}}" data-title="{{$s.Title}}" data-artist="{{$s.ArtistName}}" + data-artist-id="{{$s.ArtistID}}" data-album="{{$s.AlbumTitle}}" + data-album-id="{{$s.AlbumID}}" data-cover="{{$s.CoverArtID}}" data-starred="{{if $s.Starred}}1{{else}}0{{end}}"> {{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}} diff --git a/web/templates/partials/footer.html b/web/templates/partials/footer.html index 049511d..30958d7 100644 --- a/web/templates/partials/footer.html +++ b/web/templates/partials/footer.html @@ -3,7 +3,7 @@ -{{if .User}}{{template "player"}}{{end}} +{{if .User}}{{template "player"}}{{template "player_art_modal"}}{{end}} {{template "shortcuts_modal"}} {{template "confirm_delete_modal"}} @@ -17,10 +17,10 @@
- +
-
Nothing playing
-
+ Nothing playing +
@@ -47,6 +47,17 @@
{{end}} +{{define "player_art_modal"}} + +{{end}} + {{define "shortcuts_modal"}}