Add a web player lyrics panel with active-line highlighting and click-to-seek
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ func (h *Web) NewRouter() http.Handler {
|
|||||||
mux.HandleFunc("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke))
|
mux.HandleFunc("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke))
|
||||||
|
|
||||||
mux.HandleFunc("GET /stream/{id}", h.requireUser(h.handleStream))
|
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 /cover-art/{id}", h.requireUser(h.handleCoverArt))
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot))
|
mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot))
|
||||||
|
|||||||
@@ -286,6 +286,44 @@ body {
|
|||||||
flex: 0 0 auto;
|
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 --- */
|
/* --- Misc --- */
|
||||||
|
|
||||||
.vs-star-btn {
|
.vs-star-btn {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
this.ui.starBtn.dataset.itemId = song.id;
|
this.ui.starBtn.dataset.itemId = song.id;
|
||||||
this.ui.starBtn.classList.toggle("active", !!song.starred);
|
this.ui.starBtn.classList.toggle("active", !!song.starred);
|
||||||
this.ui.starBtn.innerHTML = song.starred ? '<i class="bi bi-star-fill"></i>' : '<i class="bi bi-star"></i>';
|
this.ui.starBtn.innerHTML = song.starred ? '<i class="bi bi-star-fill"></i>' : '<i class="bi bi-star"></i>';
|
||||||
|
this._loadLyrics(song.id);
|
||||||
|
|
||||||
if ("mediaSession" in navigator) {
|
if ("mediaSession" in navigator) {
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
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 () {
|
VibeSonicPlayer.prototype.playPause = function () {
|
||||||
if (!this.queue.length) return;
|
if (!this.queue.length) return;
|
||||||
if (this.audio.paused) {
|
if (this.audio.paused) {
|
||||||
@@ -123,6 +197,7 @@
|
|||||||
if (dur > 0) {
|
if (dur > 0) {
|
||||||
this.ui.progress.value = String((cur / dur) * 100);
|
this.ui.progress.value = String((cur / dur) * 100);
|
||||||
}
|
}
|
||||||
|
this._updateActiveLyricLine(cur * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds) {
|
||||||
@@ -165,9 +240,18 @@
|
|||||||
progress: document.getElementById("vs-progress"),
|
progress: document.getElementById("vs-progress"),
|
||||||
volumeSlider: document.getElementById("vs-volume"),
|
volumeSlider: document.getElementById("vs-volume"),
|
||||||
starBtn: document.getElementById("vs-now-star"),
|
starBtn: document.getElementById("vs-now-star"),
|
||||||
|
lyricsPanel: document.getElementById("vs-lyrics-panel"),
|
||||||
};
|
};
|
||||||
var player = new VibeSonicPlayer(audio, ui);
|
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 () {
|
document.getElementById("vs-play-btn").addEventListener("click", function () {
|
||||||
player.playPause();
|
player.playPause();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
{{define "player"}}
|
{{define "player"}}
|
||||||
<audio id="vs-audio" preload="metadata"></audio>
|
<audio id="vs-audio" preload="metadata"></audio>
|
||||||
|
<div id="vs-lyrics-panel" class="vs-lyrics-panel d-none"></div>
|
||||||
<div class="vs-player">
|
<div class="vs-player">
|
||||||
<img id="vs-player-art" class="vs-player-art" src="/static/img/default-art.svg" alt="">
|
<img id="vs-player-art" class="vs-player-art" src="/static/img/default-art.svg" alt="">
|
||||||
<div class="vs-player-meta">
|
<div class="vs-player-meta">
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<input id="vs-progress" type="range" class="form-range" min="0" max="100" value="0">
|
<input id="vs-progress" type="range" class="form-range" min="0" max="100" value="0">
|
||||||
<span id="vs-duration" class="vs-player-time">0:00</span>
|
<span id="vs-duration" class="vs-player-time">0:00</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="vs-lyrics-btn" type="button" class="vs-player-btn" title="Lyrics"><i class="bi bi-quote"></i></button>
|
||||||
<button id="vs-now-star" type="button" class="vs-star-btn" data-star-toggle data-item-type="song" data-item-id="" title="Favorite">
|
<button id="vs-now-star" type="button" class="vs-star-btn" data-star-toggle data-item-type="song" data-item-id="" title="Favorite">
|
||||||
<i class="bi bi-star"></i>
|
<i class="bi bi-star"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user