Files
vibesonic/web/static/js/app.js
T
owenandClaude Sonnet 5 933ed31550 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
2026-08-23 08:39:22 -05:00

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();
});
})();