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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user