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:
@@ -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 {
|
||||
|
||||
@@ -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 ? '<i class="bi bi-star-fill"></i>' : '<i class="bi bi-star"></i>';
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user