/* 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 ? '' : ''; 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 ? '' : ''; }; 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(); }); })();