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
634 lines
22 KiB
JavaScript
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();
|
|
});
|
|
})();
|