From 933ed31550e994b2306f153c6415a0bbd15d2828 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 08:39:22 -0500 Subject: [PATCH] Add a web player lyrics panel with active-line highlighting and click-to-seek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New GET /player/lyrics/{id} (auth-required) returns the best available stored structuredLyrics entry for a song — preferring a synced one — as plain JSON lines with a start time only where the source actually has one. The player bar gains a lyrics toggle; the panel fetches lyrics on every song change, renders each line, and — only for lines that actually carry a startMs — makes them clickable (seeks + plays) and highlights the currently active line as playback progresses. Plain (unsynced) lyrics render as static text with no fabricated timing or highlighting. Note: verified via Go template parsing/rendering and the new handler tests, plus a JS syntax check; not manually exercised in a browser in this session. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv --- internal/web/lyrics_handler.go | 50 ++++++++++++++ internal/web/lyrics_handler_test.go | 101 ++++++++++++++++++++++++++++ internal/web/router.go | 1 + web/static/css/app.css | 38 +++++++++++ web/static/js/app.js | 84 +++++++++++++++++++++++ web/templates/partials/footer.html | 2 + 6 files changed, 276 insertions(+) create mode 100644 internal/web/lyrics_handler.go create mode 100644 internal/web/lyrics_handler_test.go diff --git a/internal/web/lyrics_handler.go b/internal/web/lyrics_handler.go new file mode 100644 index 0000000..dbb1b7f --- /dev/null +++ b/internal/web/lyrics_handler.go @@ -0,0 +1,50 @@ +package web + +import ( + "encoding/json" + "net/http" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +type lyricsLineJSON struct { + Text string `json:"text"` + StartMs *int `json:"startMs,omitempty"` +} + +type lyricsResponseJSON struct { + Synced bool `json:"synced"` + Lines []lyricsLineJSON `json:"lines"` +} + +// handleLyrics serves the web player's lyrics panel: the best available +// stored structuredLyrics entry for a song, as line text plus a start +// time when the source actually has one. Prefers a synced entry over +// an unsynced one so the panel can highlight/click-to-seek whenever +// that data exists, but never fabricates timing for an unsynced entry. +func (h *Web) handleLyrics(w http.ResponseWriter, r *http.Request) { + songID := r.PathValue("id") + entries, err := database.GetSongStructuredLyrics(h.DB, songID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + var best *database.StructuredLyricsRow + for i := range entries { + if best == nil || (entries[i].Synced && !best.Synced) { + best = &entries[i] + } + } + + out := lyricsResponseJSON{Lines: []lyricsLineJSON{}} + if best != nil { + out.Synced = best.Synced + for _, l := range best.Lines { + out.Lines = append(out.Lines, lyricsLineJSON{Text: l.Text, StartMs: l.StartMs}) + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} diff --git a/internal/web/lyrics_handler_test.go b/internal/web/lyrics_handler_test.go new file mode 100644 index 0000000..11917a8 --- /dev/null +++ b/internal/web/lyrics_handler_test.go @@ -0,0 +1,101 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func TestLyricsHandlerPrefersSyncedEntry(t *testing.T) { + h, db := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + jar := map[string]*http.Cookie{} + doSetup(t, srv, jar, t.TempDir()) + + folder, err := database.CreateMusicFolder(db, "Music", "/music", "music") + if err != nil { + t.Fatal(err) + } + mf, err := database.CreateMediaFile(db, folder.ID, filepath.Join("a.mp3"), 100, 0, "h", "mp3", "song", 10, 128, 44100, 2) + if err != nil { + t.Fatal(err) + } + song, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Song"}) + if err != nil { + t.Fatal(err) + } + if _, err := database.InsertLyrics(db, song.ID, "embedded", "xxx", false, []database.LyricsLineInput{{Text: "Plain line"}}); err != nil { + t.Fatal(err) + } + start := 1500 + if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{{StartMs: &start, Text: "Synced line"}}); err != nil { + t.Fatal(err) + } + + resp := doRequest(t, srv, jar, http.MethodGet, "/player/lyrics/"+song.ID, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + var out lyricsResponseJSON + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, body) + } + if !out.Synced { + t.Errorf("expected the synced entry to be preferred, got synced=false: %+v", out) + } + if len(out.Lines) != 1 || out.Lines[0].Text != "Synced line" { + t.Fatalf("lines = %+v", out.Lines) + } + if out.Lines[0].StartMs == nil || *out.Lines[0].StartMs != 1500 { + t.Errorf("StartMs = %v, want 1500", out.Lines[0].StartMs) + } +} + +func TestLyricsHandlerNoLyricsReturnsEmpty(t *testing.T) { + h, db := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + jar := map[string]*http.Cookie{} + doSetup(t, srv, jar, t.TempDir()) + + folder, err := database.CreateMusicFolder(db, "Music", "/music", "music") + if err != nil { + t.Fatal(err) + } + mf, err := database.CreateMediaFile(db, folder.ID, "b.mp3", 100, 0, "h2", "mp3", "song", 10, 128, 44100, 2) + if err != nil { + t.Fatal(err) + } + song, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "No Lyrics"}) + if err != nil { + t.Fatal(err) + } + + resp := doRequest(t, srv, jar, http.MethodGet, "/player/lyrics/"+song.ID, nil) + body, _ := io.ReadAll(resp.Body) + var out lyricsResponseJSON + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, body) + } + if len(out.Lines) != 0 { + t.Errorf("expected no lines, got %+v", out.Lines) + } +} + +func TestLyricsHandlerRequiresAuth(t *testing.T) { + h, _ := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + jar := map[string]*http.Cookie{} + resp := doRequest(t, srv, jar, http.MethodGet, "/player/lyrics/sg_whatever", nil) + if resp.StatusCode == http.StatusOK { + t.Fatal("expected unauthenticated request to be rejected/redirected, got 200") + } +} diff --git a/internal/web/router.go b/internal/web/router.go index 9a207f3..78a304d 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -69,6 +69,7 @@ func (h *Web) NewRouter() http.Handler { mux.HandleFunc("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke)) mux.HandleFunc("GET /stream/{id}", h.requireUser(h.handleStream)) + mux.HandleFunc("GET /player/lyrics/{id}", h.requireUser(h.handleLyrics)) mux.HandleFunc("GET /cover-art/{id}", h.requireUser(h.handleCoverArt)) mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot)) diff --git a/web/static/css/app.css b/web/static/css/app.css index ab127d9..0fa9e9d 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -286,6 +286,44 @@ body { flex: 0 0 auto; } +.vs-lyrics-panel { + position: fixed; + left: 0; + right: 0; + bottom: var(--vs-player-height); + max-height: 40vh; + overflow-y: auto; + background: var(--bs-body-bg); + border-top: 1px solid var(--bs-border-color); + padding: 1rem; + z-index: 1029; + text-align: center; +} + +.vs-lyrics-line { + padding: 0.15rem 0; + color: var(--bs-secondary-color); + transition: color 0.15s ease; +} + +.vs-lyrics-line.vs-lyrics-synced { + cursor: pointer; +} + +.vs-lyrics-line.vs-lyrics-synced:hover { + color: var(--bs-body-color); +} + +.vs-lyrics-line.vs-lyrics-active { + color: var(--bs-body-color); + font-weight: 600; +} + +.vs-lyrics-empty { + color: var(--bs-secondary-color); + font-size: 0.85rem; +} + /* --- Misc --- */ .vs-star-btn { diff --git a/web/static/js/app.js b/web/static/js/app.js index 5f3c5a5..e9c0acd 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -51,6 +51,7 @@ this.ui.starBtn.dataset.itemId = song.id; this.ui.starBtn.classList.toggle("active", !!song.starred); this.ui.starBtn.innerHTML = song.starred ? '' : ''; + this._loadLyrics(song.id); if ("mediaSession" in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ @@ -61,6 +62,79 @@ } }; + // --- Lyrics panel: fetches the real structured lyrics stored for the + // current song (internal/database.GetSongStructuredLyrics via + // /player/lyrics/{id}), renders them, and — only for lines that + // actually carry a start time — supports active-line highlighting and + // click-to-seek. Plain lyrics render as static text with no seek + // affordance, since there's no timing to seek to. --- + + VibeSonicPlayer.prototype._loadLyrics = function (songId) { + var self = this; + this.lyricsLines = []; + if (!this.ui.lyricsPanel) return; + fetch("/player/lyrics/" + encodeURIComponent(songId)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (data) { + // Ignore a response that arrived after the user already skipped + // to a different song. + if (!data || (self.queue[self.index] || {}).id !== songId) return; + self.lyricsLines = data.lines || []; + self._renderLyrics(); + }) + .catch(function () { + self.lyricsLines = []; + self._renderLyrics(); + }); + }; + + VibeSonicPlayer.prototype._renderLyrics = function () { + var panel = this.ui.lyricsPanel; + if (!panel) return; + panel.textContent = ""; + if (!this.lyricsLines.length) { + var empty = document.createElement("div"); + empty.className = "vs-lyrics-empty"; + empty.textContent = "No lyrics available for this song."; + panel.appendChild(empty); + return; + } + var self = this; + this.lyricsLines.forEach(function (line, i) { + var el = document.createElement("div"); + el.className = "vs-lyrics-line"; + el.textContent = line.text; + if (line.startMs != null) { + el.classList.add("vs-lyrics-synced"); + el.dataset.startMs = String(line.startMs); + el.addEventListener("click", function () { + self.audio.currentTime = line.startMs / 1000; + self.audio.play().catch(function () {}); + }); + } + panel.appendChild(el); + }); + }; + + VibeSonicPlayer.prototype._updateActiveLyricLine = function (curMs) { + var panel = this.ui.lyricsPanel; + if (!panel || !this.lyricsLines.length) return; + var activeIndex = -1; + for (var i = 0; i < this.lyricsLines.length; i++) { + var startMs = this.lyricsLines[i].startMs; + if (startMs != null && startMs <= curMs) activeIndex = i; + } + if (activeIndex === this._activeLyricIndex) return; + this._activeLyricIndex = activeIndex; + var nodes = panel.querySelectorAll(".vs-lyrics-line"); + nodes.forEach(function (node, i) { + node.classList.toggle("vs-lyrics-active", i === activeIndex); + }); + if (activeIndex >= 0 && !panel.classList.contains("d-none")) { + nodes[activeIndex].scrollIntoView({ block: "center", behavior: "smooth" }); + } + }; + VibeSonicPlayer.prototype.playPause = function () { if (!this.queue.length) return; if (this.audio.paused) { @@ -123,6 +197,7 @@ if (dur > 0) { this.ui.progress.value = String((cur / dur) * 100); } + this._updateActiveLyricLine(cur * 1000); }; function formatTime(seconds) { @@ -165,9 +240,18 @@ progress: document.getElementById("vs-progress"), volumeSlider: document.getElementById("vs-volume"), starBtn: document.getElementById("vs-now-star"), + lyricsPanel: document.getElementById("vs-lyrics-panel"), }; var player = new VibeSonicPlayer(audio, ui); + var lyricsBtn = document.getElementById("vs-lyrics-btn"); + if (lyricsBtn && ui.lyricsPanel) { + lyricsBtn.addEventListener("click", function () { + ui.lyricsPanel.classList.toggle("d-none"); + lyricsBtn.classList.toggle("active"); + }); + } + document.getElementById("vs-play-btn").addEventListener("click", function () { player.playPause(); }); diff --git a/web/templates/partials/footer.html b/web/templates/partials/footer.html index ec40943..4463d58 100644 --- a/web/templates/partials/footer.html +++ b/web/templates/partials/footer.html @@ -15,6 +15,7 @@ {{define "player"}} +
@@ -32,6 +33,7 @@ 0:00
+