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
384 lines
13 KiB
JavaScript
384 lines
13 KiB
JavaScript
/* VibeSonic — theme toggle + bottom player. Deliberately minimal
|
|
* vanilla JS: no bundler, no framework. */
|
|
(function () {
|
|
"use strict";
|
|
|
|
// --- Theme toggle ---
|
|
function initTheme() {
|
|
var toggle = document.querySelector("[data-theme-toggle]");
|
|
if (!toggle) return;
|
|
toggle.addEventListener("click", function () {
|
|
var root = document.documentElement;
|
|
var current = root.getAttribute("data-bs-theme") || "dark";
|
|
var next = current === "dark" ? "light" : "dark";
|
|
root.setAttribute("data-bs-theme", next);
|
|
try {
|
|
localStorage.setItem("vs-theme", next);
|
|
} catch (e) {
|
|
/* storage may be unavailable (private browsing); theme just won't persist */
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Player ---
|
|
function VibeSonicPlayer(audioEl, ui) {
|
|
this.audio = audioEl;
|
|
this.ui = ui;
|
|
this.queue = [];
|
|
this.index = -1;
|
|
this.audio.addEventListener("timeupdate", this._onTimeUpdate.bind(this));
|
|
this.audio.addEventListener("loadedmetadata", this._onTimeUpdate.bind(this));
|
|
this.audio.addEventListener("ended", this.next.bind(this));
|
|
this.audio.addEventListener("play", this._syncPlayIcon.bind(this));
|
|
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
|
}
|
|
|
|
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
|
this.queue = songs;
|
|
this.index = startIndex || 0;
|
|
this._loadCurrent();
|
|
this.audio.play().catch(function () {});
|
|
};
|
|
|
|
VibeSonicPlayer.prototype._loadCurrent = function () {
|
|
var song = this.queue[this.index];
|
|
if (!song) return;
|
|
this.audio.src = "/stream/" + encodeURIComponent(song.id);
|
|
this.ui.title.textContent = song.title || "";
|
|
this.ui.artist.textContent = song.artist || "";
|
|
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;
|
|
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({
|
|
title: song.title || "",
|
|
artist: song.artist || "",
|
|
album: song.album || "",
|
|
});
|
|
}
|
|
};
|
|
|
|
// --- 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) {
|
|
this.audio.play().catch(function () {});
|
|
} else {
|
|
this.audio.pause();
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.next = function () {
|
|
if (this.index + 1 < this.queue.length) {
|
|
this.index++;
|
|
this._loadCurrent();
|
|
this.audio.play().catch(function () {});
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.prev = function () {
|
|
if (this.audio.currentTime > 3) {
|
|
this.audio.currentTime = 0;
|
|
return;
|
|
}
|
|
if (this.index > 0) {
|
|
this.index--;
|
|
this._loadCurrent();
|
|
this.audio.play().catch(function () {});
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
|
if (this.audio.duration) {
|
|
this.audio.currentTime = fraction * this.audio.duration;
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.seekBy = function (deltaSeconds) {
|
|
this.audio.currentTime = Math.max(0, this.audio.currentTime + deltaSeconds);
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.setVolume = function (v) {
|
|
this.audio.volume = Math.min(1, Math.max(0, v));
|
|
this.ui.volumeSlider.value = this.audio.volume;
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.toggleMute = function () {
|
|
this.audio.muted = !this.audio.muted;
|
|
};
|
|
|
|
VibeSonicPlayer.prototype._syncPlayIcon = function () {
|
|
this.ui.playBtn.innerHTML = this.audio.paused
|
|
? '<i class="bi bi-play-fill"></i>'
|
|
: '<i class="bi bi-pause-fill"></i>';
|
|
};
|
|
|
|
VibeSonicPlayer.prototype._onTimeUpdate = function () {
|
|
var cur = this.audio.currentTime || 0;
|
|
var dur = this.audio.duration || 0;
|
|
this.ui.currentTime.textContent = formatTime(cur);
|
|
this.ui.duration.textContent = formatTime(dur);
|
|
if (dur > 0) {
|
|
this.ui.progress.value = String((cur / dur) * 100);
|
|
}
|
|
this._updateActiveLyricLine(cur * 1000);
|
|
};
|
|
|
|
function formatTime(seconds) {
|
|
if (!isFinite(seconds) || seconds < 0) return "0:00";
|
|
var m = Math.floor(seconds / 60);
|
|
var s = Math.floor(seconds % 60);
|
|
return m + ":" + (s < 10 ? "0" : "") + s;
|
|
}
|
|
|
|
function collectQueueFrom(el) {
|
|
var container = el.closest("[data-playable-list]") || document;
|
|
var nodes = container.querySelectorAll(".vs-playable");
|
|
var songs = [];
|
|
var startIndex = 0;
|
|
nodes.forEach(function (node, i) {
|
|
if (node === el || node.contains(el)) startIndex = i;
|
|
songs.push({
|
|
id: node.dataset.songId,
|
|
title: node.dataset.title,
|
|
artist: node.dataset.artist,
|
|
album: node.dataset.album,
|
|
cover: node.dataset.cover,
|
|
starred: node.dataset.starred === "1",
|
|
});
|
|
});
|
|
return { songs: songs, startIndex: startIndex };
|
|
}
|
|
|
|
function initPlayer() {
|
|
var audio = document.getElementById("vs-audio");
|
|
if (!audio) return null;
|
|
|
|
var ui = {
|
|
art: document.getElementById("vs-player-art"),
|
|
title: document.getElementById("vs-player-title"),
|
|
artist: document.getElementById("vs-player-artist"),
|
|
playBtn: document.getElementById("vs-play-btn"),
|
|
currentTime: document.getElementById("vs-current-time"),
|
|
duration: document.getElementById("vs-duration"),
|
|
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();
|
|
});
|
|
document.getElementById("vs-next-btn").addEventListener("click", function () {
|
|
player.next();
|
|
});
|
|
document.getElementById("vs-prev-btn").addEventListener("click", function () {
|
|
player.prev();
|
|
});
|
|
ui.progress.addEventListener("input", function () {
|
|
player.seekTo(Number(ui.progress.value) / 100);
|
|
});
|
|
ui.volumeSlider.addEventListener("input", function () {
|
|
player.setVolume(Number(ui.volumeSlider.value));
|
|
});
|
|
document.getElementById("vs-mute-btn").addEventListener("click", function () {
|
|
player.toggleMute();
|
|
});
|
|
|
|
document.addEventListener("click", function (ev) {
|
|
var el = ev.target.closest(".vs-playable");
|
|
if (!el) return;
|
|
ev.preventDefault();
|
|
var q = collectQueueFrom(el);
|
|
player.playQueue(q.songs, q.startIndex);
|
|
});
|
|
|
|
if ("mediaSession" in navigator) {
|
|
navigator.mediaSession.setActionHandler("play", function () { player.playPause(); });
|
|
navigator.mediaSession.setActionHandler("pause", function () { player.playPause(); });
|
|
navigator.mediaSession.setActionHandler("previoustrack", function () { player.prev(); });
|
|
navigator.mediaSession.setActionHandler("nexttrack", function () { player.next(); });
|
|
}
|
|
|
|
player.setVolume(1);
|
|
return player;
|
|
}
|
|
|
|
function isTypingTarget(el) {
|
|
if (!el) return false;
|
|
var tag = el.tagName;
|
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
|
}
|
|
|
|
function initKeyboardShortcuts(player) {
|
|
document.addEventListener("keydown", function (ev) {
|
|
if (isTypingTarget(document.activeElement)) return;
|
|
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
|
|
|
switch (ev.key) {
|
|
case " ":
|
|
case "k":
|
|
case "K":
|
|
ev.preventDefault();
|
|
if (player) player.playPause();
|
|
break;
|
|
case "j":
|
|
case "J":
|
|
if (player) player.prev();
|
|
break;
|
|
case "l":
|
|
case "L":
|
|
if (player) player.next();
|
|
break;
|
|
case "ArrowLeft":
|
|
if (player) player.seekBy(-5);
|
|
break;
|
|
case "ArrowRight":
|
|
if (player) player.seekBy(5);
|
|
break;
|
|
case "ArrowUp":
|
|
ev.preventDefault();
|
|
if (player) player.setVolume(player.audio.volume + 0.1);
|
|
break;
|
|
case "ArrowDown":
|
|
ev.preventDefault();
|
|
if (player) player.setVolume(player.audio.volume - 0.1);
|
|
break;
|
|
case "m":
|
|
case "M":
|
|
if (player) player.toggleMute();
|
|
break;
|
|
case "/":
|
|
var search = document.getElementById("vs-search-input");
|
|
if (search) {
|
|
ev.preventDefault();
|
|
search.focus();
|
|
}
|
|
break;
|
|
case "?":
|
|
var helpModalEl = document.getElementById("vs-shortcuts-modal");
|
|
if (helpModalEl && window.bootstrap) {
|
|
new window.bootstrap.Modal(helpModalEl).show();
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
function initStarButtons() {
|
|
document.addEventListener("click", function (ev) {
|
|
var btn = ev.target.closest("[data-star-toggle]");
|
|
if (!btn) return;
|
|
ev.preventDefault();
|
|
var itemType = btn.dataset.itemType;
|
|
var itemId = btn.dataset.itemId;
|
|
var active = btn.classList.contains("active");
|
|
var form = document.createElement("form");
|
|
form.method = "POST";
|
|
form.action = active ? "/favorites/unstar" : "/favorites/star";
|
|
form.style.display = "none";
|
|
[["item_type", itemType], ["item_id", itemId], ["csrf_token", window.vsCSRFToken || ""], ["next", location.pathname]].forEach(function (pair) {
|
|
var input = document.createElement("input");
|
|
input.name = pair[0];
|
|
input.value = pair[1];
|
|
form.appendChild(input);
|
|
});
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initTheme();
|
|
var player = initPlayer();
|
|
initKeyboardShortcuts(player);
|
|
initStarButtons();
|
|
});
|
|
})();
|