Files
vibesonic/web/static/js/app.js
T
owenandClaude Sonnet 5 9a3f1e4a24 Make the now-playing bar's title/artist clickable and add a cover art lightbox
The song title links to its album, the artist name links to the artist
page, and clicking the cover art opens a larger version in a modal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 22:04:36 -05:00

634 lines
22 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.shuffle = false;
this.repeat = "off"; // "off" | "all" | "one"
this.shuffleOrder = []; // permutation of queue indices; shufflePos points into it
this.shufflePos = -1;
try {
this.shuffle = localStorage.getItem("vs-shuffle") === "1";
var storedRepeat = localStorage.getItem("vs-repeat");
if (storedRepeat === "all" || storedRepeat === "one") this.repeat = storedRepeat;
} catch (e) {
/* storage may be unavailable (private browsing); state just won't persist */
}
this.audio.addEventListener("timeupdate", this._onTimeUpdate.bind(this));
this.audio.addEventListener("loadedmetadata", this._onTimeUpdate.bind(this));
this.audio.addEventListener("ended", this._onEnded.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._reshuffle();
this._loadCurrent();
this.audio.play().catch(function () {});
};
// _reshuffle rebuilds the shuffle order (a Fisher-Yates permutation of
// every queue index except the currently-playing one, which is pinned
// to the front) whenever the queue changes or shuffle is toggled on,
// so "next" has a fresh random order to draw from without immediately
// repeating the track that's already playing.
VibeSonicPlayer.prototype._reshuffle = function () {
var order = [];
for (var i = 0; i < this.queue.length; i++) {
if (i !== this.index) order.push(i);
}
for (var j = order.length - 1; j > 0; j--) {
var k = Math.floor(Math.random() * (j + 1));
var tmp = order[j];
order[j] = order[k];
order[k] = tmp;
}
order.unshift(this.index);
this.shuffleOrder = order;
this.shufflePos = 0;
};
VibeSonicPlayer.prototype.setShuffle = function (on) {
this.shuffle = on;
try {
localStorage.setItem("vs-shuffle", on ? "1" : "0");
} catch (e) {}
if (on) this._reshuffle();
if (this.ui.shuffleBtn) this.ui.shuffleBtn.classList.toggle("active", on);
};
VibeSonicPlayer.prototype.cycleRepeat = function () {
this.repeat = this.repeat === "off" ? "all" : this.repeat === "all" ? "one" : "off";
try {
localStorage.setItem("vs-repeat", this.repeat);
} catch (e) {}
this._syncRepeatButton();
};
VibeSonicPlayer.prototype._syncRepeatButton = function () {
var btn = this.ui.repeatBtn;
if (!btn) return;
btn.dataset.repeat = this.repeat;
btn.classList.toggle("active", this.repeat !== "off");
var icon = btn.querySelector("i");
if (icon) icon.className = this.repeat === "one" ? "bi bi-repeat-1" : "bi bi-repeat";
btn.title = this.repeat === "off" ? "Repeat: off" : this.repeat === "all" ? "Repeat: all" : "Repeat: one";
};
VibeSonicPlayer.prototype._onEnded = function () {
if (this.repeat === "one") {
this.audio.currentTime = 0;
this.audio.play().catch(function () {});
return;
}
this.next();
};
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.title.href = song.albumId ? "/albums/" + encodeURIComponent(song.albumId) : "javascript:void(0)";
this.ui.artist.textContent = song.artist || "";
this.ui.artist.href = song.artistId ? "/artists/" + encodeURIComponent(song.artistId) : "javascript:void(0)";
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.queue.length) return;
if (this.shuffle) {
if (this.shufflePos + 1 < this.shuffleOrder.length) {
this.shufflePos++;
} else if (this.repeat === "all") {
this._reshuffle();
} else {
return;
}
this.index = this.shuffleOrder[this.shufflePos];
} else if (this.index + 1 < this.queue.length) {
this.index++;
} else if (this.repeat === "all") {
this.index = 0;
} else {
return;
}
this._loadCurrent();
this.audio.play().catch(function () {});
};
VibeSonicPlayer.prototype.prev = function () {
if (this.audio.currentTime > 3) {
this.audio.currentTime = 0;
return;
}
if (this.shuffle) {
if (this.shufflePos <= 0) return;
this.shufflePos--;
this.index = this.shuffleOrder[this.shufflePos];
} else if (this.index > 0) {
this.index--;
} else {
return;
}
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,
artistId: node.dataset.artistId,
album: node.dataset.album,
albumId: node.dataset.albumId,
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"),
shuffleBtn: document.getElementById("vs-shuffle-btn"),
repeatBtn: document.getElementById("vs-repeat-btn"),
};
var player = new VibeSonicPlayer(audio, ui);
player.setShuffle(player.shuffle);
player._syncRepeatButton();
if (ui.shuffleBtn) {
ui.shuffleBtn.addEventListener("click", function () {
player.setShuffle(!player.shuffle);
});
}
if (ui.repeatBtn) {
ui.repeatBtn.addEventListener("click", function () {
player.cycleRepeat();
});
}
var artModalEl = document.getElementById("vs-player-art-modal");
if (ui.art && artModalEl && window.bootstrap) {
var artModalImg = document.getElementById("vs-player-art-modal-img");
var artModal = new bootstrap.Modal(artModalEl);
ui.art.addEventListener("click", function () {
var song = player.queue[player.index];
artModalImg.src = song && song.cover
? "/cover-art/" + encodeURIComponent(song.cover) + "?size=1200"
: "/static/img/default-art.svg";
artModalImg.alt = song ? (song.title || "") : "";
artModal.show();
});
}
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.requestSubmit();
});
}
// --- Inline navigation ---
// Fetch server-rendered pages and replace only the main content area. The
// audio element and player live outside that area, so playback continues
// uninterrupted while the URL, title, and active sidebar item still track
// the current page normally.
function initInlineNavigation() {
var pending = null;
var currentURL = location.href;
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
function isLocalHTTP(url) {
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === location.origin;
}
function shouldHandleLink(link, ev) {
if (!link || ev.defaultPrevented || ev.button !== 0) return false;
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
if (link.target || link.hasAttribute("download") || link.dataset.noInline != null) return false;
var url = new URL(link.href, location.href);
if (!isLocalHTTP(url)) return false;
if (url.pathname.indexOf("/static/") === 0 || url.pathname.indexOf("/stream/") === 0 ||
url.pathname.indexOf("/cover-art/") === 0 || url.pathname.indexOf("/share/") === 0 ||
/\/export$/.test(url.pathname)) return false;
// Let the browser handle an in-page fragment without a network request.
if (url.pathname === location.pathname && url.search === location.search && url.hash) return false;
return true;
}
function setBusy(busy) {
var main = document.querySelector(".vs-main");
if (!main) return;
if (busy) main.setAttribute("aria-busy", "true");
else main.removeAttribute("aria-busy");
}
function applyPage(html, responseURL, push, restoreScroll) {
var parsed = new DOMParser().parseFromString(html, "text/html");
var nextMain = parsed.querySelector(".vs-main");
var oldMain = document.querySelector(".vs-main");
// Login, setup, shared, and error documents are intentionally full-page.
if (!nextMain || !oldMain) return false;
oldMain.replaceWith(nextMain);
document.title = parsed.title || document.title;
var tokenScript = parsed.querySelector("script[data-vs-csrf]");
if (tokenScript) window.vsCSRFToken = tokenScript.dataset.vsCsrf;
var activeHref = parsed.querySelector(".vs-sidebar .nav-link.active");
document.querySelectorAll(".vs-sidebar .nav-link").forEach(function (link) {
link.classList.toggle("active", !!activeHref && link.getAttribute("href") === activeHref.getAttribute("href"));
});
currentURL = responseURL;
if (push) history.pushState({ vsInline: true, scrollY: 0 }, "", responseURL);
var top = typeof restoreScroll === "number" ? restoreScroll : 0;
window.scrollTo(0, top);
var autofocus = nextMain.querySelector("[autofocus]");
if (autofocus) autofocus.focus({ preventScroll: true });
return true;
}
function navigate(url, options) {
options = options || {};
if (pending) pending.abort();
var controller = new AbortController();
pending = controller;
setBusy(true);
var request = {
method: options.method || "GET",
body: options.body || null,
signal: controller.signal,
headers: { "X-VibeSonic-Inline": "1" },
};
return fetch(url, request).then(function (response) {
if (!response.ok || !(response.headers.get("Content-Type") || "").includes("text/html")) {
throw new Error("not an inline page");
}
return response.text().then(function (html) {
if (!applyPage(html, response.url, options.push !== false, options.restoreScroll)) {
location.assign(response.url);
}
});
}).catch(function (err) {
if (err.name !== "AbortError") {
if ((options.method || "GET") === "GET") location.assign(url);
else location.reload();
}
}).finally(function () {
if (pending === controller) {
pending = null;
setBusy(false);
}
});
}
document.addEventListener("click", function (ev) {
var link = ev.target.closest("a[href]");
if (!shouldHandleLink(link, ev)) return;
ev.preventDefault();
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
navigate(link.href);
});
document.addEventListener("submit", function (ev) {
var form = ev.target;
if (ev.defaultPrevented || !(form instanceof HTMLFormElement) || form.dataset.noInline != null) return;
var url = new URL(form.action || location.href, location.href);
if (!isLocalHTTP(url) || url.pathname === "/logout") return;
ev.preventDefault();
var data = new FormData(form);
var method = (form.method || "GET").toUpperCase();
if (method === "GET") {
url.search = new URLSearchParams(data).toString();
navigate(url.href);
} else {
navigate(url.href, { method: method, body: data });
}
});
window.addEventListener("popstate", function (ev) {
navigate(location.href, {
push: false,
restoreScroll: ev.state && typeof ev.state.scrollY === "number" ? ev.state.scrollY : 0,
});
});
}
document.addEventListener("DOMContentLoaded", function () {
initTheme();
var player = initPlayer();
initKeyboardShortcuts(player);
initStarButtons();
initInlineNavigation();
});
})();