aboutsummaryrefslogtreecommitdiffstats
path: root/assets/js/site.js
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /assets/js/site.js
downloadwww-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.tar.gz
www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to 'assets/js/site.js')
-rw-r--r--assets/js/site.js1341
1 files changed, 1341 insertions, 0 deletions
diff --git a/assets/js/site.js b/assets/js/site.js
new file mode 100644
index 0000000..63ac6a8
--- /dev/null
+++ b/assets/js/site.js
@@ -0,0 +1,1341 @@
+(function () {
+ function byId(id) {
+ return document.getElementById(id);
+ }
+
+ /* ========================================================================
+ Theme Toggle (localStorage)
+ ======================================================================== */
+ function applyTheme(theme) {
+ var isLight = theme === "light";
+ document.body.classList.add("theme-transitioning");
+ window.setTimeout(function () {
+ document.body.classList.remove("theme-transitioning");
+ }, 400);
+ if (theme === "light") {
+ document.body.classList.add("light-mode");
+ } else {
+ document.body.classList.remove("light-mode");
+ }
+ document.documentElement.style.colorScheme = isLight ? "light" : "dark";
+ var themeColor = document.querySelector('meta[name="theme-color"]');
+ if (themeColor) {
+ themeColor.setAttribute("content", isLight ? "#fff8f1" : "#000000");
+ }
+ var btn = byId("theme-toggle");
+ if (btn) {
+ btn.textContent = theme === "light" ? "\u263E" : "\u2600";
+ btn.setAttribute("title",
+ theme === "light"
+ ? "Switch to dark theme"
+ : "Switch to light theme"
+ );
+ btn.setAttribute("aria-label",
+ theme === "light"
+ ? "Switch to dark theme"
+ : "Switch to light theme"
+ );
+ }
+ // Sync guestbook iframes
+ ["guestbook-form-iframe", "guestbook-comments-iframe"].forEach(function (id) {
+ var frame = byId(id);
+ if (!frame) return;
+ var src = frame.getAttribute("src") || "";
+ var base = src.split("?")[0];
+ var next = theme === "light" ? base + "?theme=light" : base;
+ if (src !== next) frame.setAttribute("src", next);
+ });
+ }
+
+ var stored = localStorage.getItem("siteTheme");
+ var prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
+ var savedTheme = stored || (prefersLight ? "light" : "dark");
+ applyTheme(savedTheme);
+
+ if (window.matchMedia) {
+ window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function (e) {
+ if (!localStorage.getItem("siteTheme")) {
+ applyTheme(e.matches ? "light" : "dark");
+ }
+ });
+ }
+
+ function syncHeaderOffset() {
+ var header = document.querySelector("header.site-header");
+ if (!header) return;
+ document.documentElement.style.setProperty(
+ "--site-header-height",
+ Math.ceil(header.getBoundingClientRect().height) + "px"
+ );
+ }
+
+ window.addEventListener("load", syncHeaderOffset);
+ window.addEventListener("resize", syncHeaderOffset);
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", syncHeaderOffset);
+ } else {
+ syncHeaderOffset();
+ }
+
+ var themeBtn = byId("theme-toggle");
+ if (themeBtn) {
+ themeBtn.addEventListener("click", function () {
+ var next = document.body.classList.contains("light-mode") ? "dark" : "light";
+ localStorage.setItem("siteTheme", next);
+ applyTheme(next);
+ });
+ }
+
+ /* ========================================================================
+ Comfort spacing toggle (localStorage, site-wide)
+ ======================================================================== */
+ (function initComfortSpacing() {
+ if ((location.pathname || "").indexOf("/startpage") !== -1) return;
+ var KEY = "siteComfortSpacing_v1";
+ var btn = byId("comfort-toggle");
+ if (!btn) return;
+
+ function applyComfort(on) {
+ document.body.classList.toggle("comfort-spacing", on);
+ btn.setAttribute("aria-pressed", on ? "true" : "false");
+ btn.setAttribute("title", on ? "Comfort spacing: ON" : "Comfort spacing: OFF");
+ btn.setAttribute("aria-label", on ? "Comfort spacing on" : "Comfort spacing off");
+ }
+
+ applyComfort(localStorage.getItem(KEY) === "1");
+
+ btn.addEventListener("click", function () {
+ var next = !document.body.classList.contains("comfort-spacing");
+ localStorage.setItem(KEY, next ? "1" : "0");
+ applyComfort(next);
+ });
+ })();
+
+ /* ========================================================================
+ Menu & Language Dropdowns
+ ======================================================================== */
+ const menuBtn = byId("menu-toggle");
+ const nav = byId("site-nav");
+
+ const langToggle = byId("lang-toggle");
+ const langMenu = byId("lang-options");
+ const currentLang = byId("current-lang");
+
+ let openDropdown = null;
+
+ function closeMenu() {
+ if (!menuBtn || !nav) return;
+ menuBtn.classList.remove("active");
+ menuBtn.setAttribute("aria-expanded", "false");
+ nav.hidden = true;
+ if (openDropdown === "menu") openDropdown = null;
+ }
+
+ function closeLang() {
+ if (!langToggle || !langMenu) return;
+ langToggle.setAttribute("aria-expanded", "false");
+ langMenu.hidden = true;
+ if (openDropdown === "lang") openDropdown = null;
+ }
+
+ function toggleMenu(e) {
+ if (!menuBtn || !nav) return;
+ e.stopPropagation();
+
+ if (openDropdown === "lang") closeLang();
+
+ const open = menuBtn.getAttribute("aria-expanded") === "true";
+ if (open) {
+ closeMenu();
+ return;
+ }
+
+ menuBtn.classList.add("active");
+ menuBtn.setAttribute("aria-expanded", "true");
+ nav.hidden = false;
+ openDropdown = "menu";
+ }
+
+ function toggleLang(e) {
+ if (!langToggle || !langMenu) return;
+ e.stopPropagation();
+
+ if (openDropdown === "menu") closeMenu();
+
+ const open = langToggle.getAttribute("aria-expanded") === "true";
+ if (open) {
+ closeLang();
+ return;
+ }
+
+ langToggle.setAttribute("aria-expanded", "true");
+ langMenu.hidden = false;
+ openDropdown = "lang";
+ }
+
+ if (nav) nav.hidden = true;
+ if (langMenu) langMenu.hidden = true;
+
+ if (menuBtn && nav) {
+ menuBtn.addEventListener("click", toggleMenu);
+ nav.addEventListener("click", (e) => e.stopPropagation());
+ }
+
+ if (langToggle && langMenu) {
+ langToggle.addEventListener("click", toggleLang);
+ langMenu.addEventListener("click", (e) => e.stopPropagation());
+ }
+
+ if ((menuBtn && nav) || (langToggle && langMenu)) {
+ document.addEventListener("click", function (e) {
+ if (
+ menuBtn && nav &&
+ (menuBtn.contains(e.target) || nav.contains(e.target))
+ ) {
+ return;
+ }
+ if (
+ langToggle && langMenu &&
+ (langToggle.contains(e.target) || langMenu.contains(e.target))
+ ) {
+ return;
+ }
+ closeMenu();
+ closeLang();
+ });
+
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape") {
+ closeMenu();
+ closeLang();
+ }
+ });
+ }
+
+ if (currentLang) {
+ const docLang = (document.documentElement.lang || "en").toLowerCase();
+ currentLang.textContent = docLang === "zh" ? "ZH" : docLang === "ja" ? "JP" : "EN";
+ }
+
+ if (langMenu) {
+ langMenu.querySelectorAll("a[data-lang]").forEach((a) => {
+ a.addEventListener("click", function () {
+ localStorage.setItem("preferredLang", this.getAttribute("data-lang") || "en");
+ });
+ });
+ }
+
+ // Language menu hrefs: convert current page to *_zh.php / *_jp.php.
+ function updateLangMenuHrefs() {
+ const links = document.querySelectorAll("a[data-lang-href][data-lang]");
+ if (!links.length) return;
+
+ let path = window.location.pathname || "/";
+ if (!path.startsWith("/")) path = "/" + path;
+
+ const isDir = path.endsWith("/");
+ const file = isDir ? "index.php" : (path.split("/").pop() || "index.php");
+ const base = isDir ? path : path.slice(0, -file.length);
+
+ const baseName = file.replace(/_(zh|jp)\.php$/i, ".php");
+ const map = {
+ en: baseName,
+ zh: baseName.replace(/(_(zh|jp))?\.php$/i, "_zh.php"),
+ jp: baseName.replace(/(_(zh|jp))?\.php$/i, "_jp.php"),
+ };
+
+ links.forEach((a) => {
+ const lang = (a.getAttribute("data-lang") || "en").toLowerCase();
+ const targetFile = map[lang] || map.en;
+ a.href = base + targetFile;
+ });
+ }
+
+ function updateAriaCurrent() {
+ const path = window.location.pathname || "/";
+ const normalized = path.startsWith("/") ? path : "/" + path;
+ const isDir = normalized.endsWith("/");
+ const file = isDir ? "index.php" : (normalized.split("/").pop() || "index.php");
+ const base = isDir ? normalized : normalized.slice(0, -file.length);
+ const indexPath = base + "index.php";
+ const dirPath = base || "/";
+ const candidates = new Set([normalized, indexPath, dirPath, dirPath.replace(/\/$/, "")]);
+
+ document.querySelectorAll("#site-nav a[href]").forEach((a) => {
+ const href = a.getAttribute("href") || "";
+ const hrefUrl = new URL(href, window.location.origin);
+ if (hrefUrl.origin !== window.location.origin) {
+ a.removeAttribute("aria-current");
+ return;
+ }
+ const hrefPath = hrefUrl.pathname;
+ if (candidates.has(hrefPath)) {
+ a.setAttribute("aria-current", "page");
+ } else {
+ a.removeAttribute("aria-current");
+ }
+ });
+
+ const docLang = (document.documentElement.lang || "en").toLowerCase();
+ // "ja" is valid BCP-47 for Japanese but we label it "jp" in data-lang
+ const normalizedDocLang = docLang === "ja" ? "jp" : docLang;
+ document.querySelectorAll("#lang-options a[data-lang]").forEach((a) => {
+ const lang = (a.getAttribute("data-lang") || "en").toLowerCase();
+ if (lang === normalizedDocLang) {
+ a.setAttribute("aria-current", "page");
+ } else {
+ a.removeAttribute("aria-current");
+ }
+ });
+ }
+
+ // Run once now...
+ updateLangMenuHrefs();
+ updateAriaCurrent();
+ // ...and again after layout/injection settles.
+ setTimeout(updateLangMenuHrefs, 0);
+ setTimeout(updateAriaCurrent, 0);
+
+ /* ========================================================================
+ Skeleton Screens — replace static "Loading…" text
+ ======================================================================== */
+ function injectSkeleton(container) {
+ if (!container) return;
+ container.innerHTML =
+ '<div class="skeleton-line"></div>' +
+ '<div class="skeleton-line"></div>' +
+ '<div class="skeleton-line"></div>' +
+ '<div class="skeleton-line"></div>';
+ }
+
+ /* ========================================================================
+ Home page: Blog list (optional)
+ ======================================================================== */
+ const blogList = byId("blog-list");
+ if (blogList) {
+ injectSkeleton(blogList);
+
+ const blogHost = "https://blog.sillylaird.ca";
+ const ctrl = new AbortController();
+ const timeout = setTimeout(() => ctrl.abort(), 3500);
+
+ fetch(blogHost + "/api/posts.php", { signal: ctrl.signal })
+ .then((r) => r.json())
+ .then((posts) => {
+ blogList.textContent = "";
+
+ const top = document.createElement("div");
+
+ const strong = document.createElement("strong");
+ strong.textContent = "Posts";
+ top.appendChild(strong);
+
+ top.appendChild(document.createTextNode(" ("));
+ const allLink = document.createElement("a");
+ allLink.href = blogHost;
+ allLink.textContent = "all";
+ top.appendChild(allLink);
+ top.appendChild(document.createTextNode(")"));
+
+ blogList.appendChild(top);
+
+ const list = document.createElement("ol");
+ (posts || []).forEach((post) => {
+ const li = document.createElement("li");
+
+ const a = document.createElement("a");
+ a.href = blogHost + "/" + String(post.path || "").replace(/^\/+/, "");
+ a.textContent = String(post.title || "Untitled");
+
+ const date = document.createElement("div");
+ date.className = "muted";
+
+ const y = String(post.year || "");
+ const m = String(post.month || "").padStart(2, "0");
+ const d = String(post.day || "").padStart(2, "0");
+ const iso = y && m && d ? `${y}-${m}-${d}` : "";
+
+ const time = document.createElement("time");
+ if (iso) time.setAttribute("datetime", iso);
+ time.textContent = iso || "";
+
+ li.appendChild(a);
+ li.appendChild(document.createElement("br"));
+ date.appendChild(time);
+ li.appendChild(date);
+
+ list.appendChild(li);
+ });
+
+ blogList.appendChild(list);
+
+ if (!list.children.length) {
+ const empty = document.createElement("div");
+ empty.className = "muted";
+ empty.textContent = "No posts yet.";
+ blogList.appendChild(empty);
+ }
+ })
+ .catch((err) => {
+ blogList.textContent = err && err.name === "AbortError"
+ ? "Blog list timed out."
+ : "Cannot load posts right now.";
+ })
+ .finally(() => clearTimeout(timeout));
+ }
+
+ /* ========================================================================
+ Home page: Changelog list (optional)
+ ======================================================================== */
+ const changelogList = byId("changelog-list");
+ if (changelogList) {
+ injectSkeleton(changelogList);
+
+ const changelogPage = "https://www.sillylaird.ca/changelog/latest.php";
+ const ctrl = new AbortController();
+ const timeout = setTimeout(() => ctrl.abort(), 3500);
+
+ fetch(changelogPage, { signal: ctrl.signal })
+ .then((r) => r.text())
+ .then((html) => {
+ changelogList.textContent = "";
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const entries = Array.from(doc.querySelectorAll(".changelog-entry"));
+
+ const top = document.createElement("div");
+
+ const strong = document.createElement("strong");
+ strong.textContent = "Entries";
+ top.appendChild(strong);
+
+ top.appendChild(document.createTextNode(" ("));
+ const allLink = document.createElement("a");
+ allLink.href = "https://www.sillylaird.ca/changelog/";
+ allLink.textContent = "all";
+ top.appendChild(allLink);
+ top.appendChild(document.createTextNode(")"));
+
+ changelogList.appendChild(top);
+
+ const list = document.createElement("ol");
+ entries.forEach((entry) => {
+ const li = document.createElement("li");
+
+ const titleText = (entry.querySelector("strong, b")?.textContent || "").trim();
+ const timeEl = entry.querySelector("time");
+ const dateText = (timeEl?.textContent || "").trim();
+ const dateIso = (timeEl?.getAttribute("datetime") || "").trim();
+ const bodyEl = entry.querySelector(".text");
+ const bodyText = (bodyEl?.textContent || "").trim();
+
+ const title = document.createElement("strong");
+ if (titleText) {
+ title.textContent = titleText;
+ li.appendChild(title);
+
+ if (dateText) {
+ const date = document.createElement("div");
+ date.className = "muted";
+ const time = document.createElement("time");
+ if (dateIso) time.setAttribute("datetime", dateIso);
+ time.textContent = dateText;
+ date.appendChild(time);
+ li.appendChild(date);
+ }
+ } else if (dateText) {
+ title.textContent = dateText;
+ li.appendChild(title);
+ }
+
+ if (bodyText) {
+ const body = document.createElement("div");
+ body.className = "changelog-body";
+ body.textContent = bodyText;
+ li.appendChild(body);
+ }
+
+ list.appendChild(li);
+ });
+
+ changelogList.appendChild(list);
+
+ const filesSection = doc.querySelector(".changelog-files-section");
+ if (filesSection) {
+ changelogList.appendChild(document.importNode(filesSection, true));
+ }
+
+ if (!list.children.length) {
+ const empty = document.createElement("div");
+ empty.className = "muted";
+ empty.textContent = "No updates yet.";
+ changelogList.appendChild(empty);
+ }
+ })
+ .catch((err) => {
+ changelogList.textContent = err && err.name === "AbortError"
+ ? "Changelog list timed out."
+ : "Cannot load changelog right now.";
+ })
+ .finally(() => clearTimeout(timeout));
+ }
+
+ /* ========================================================================
+ Guestbook helpers
+ ======================================================================== */
+ const formRefreshBtn = byId("guestbook-form-refresh");
+ if (formRefreshBtn) {
+ formRefreshBtn.addEventListener("click", () => {
+ const frame = byId("guestbook-form-iframe");
+ if (!frame) return;
+ setIframeLoading(frame);
+ resetIframe(frame);
+ });
+ }
+
+ function setIframeLoading(frame) {
+ if (!frame) return;
+ const clip = frame.closest(".iframe-clip");
+ if (!clip) return;
+ clip.classList.add("is-loading");
+ const onLoad = () => {
+ clip.classList.remove("is-loading");
+ };
+ frame.addEventListener("load", onLoad, { once: true });
+ }
+
+ function resetIframe(frame) {
+ if (!frame) return;
+ const src = frame.getAttribute("src") || frame.src;
+ if (src) {
+ frame.setAttribute("src", src);
+ return;
+ }
+ if (frame.contentWindow) frame.contentWindow.location.reload();
+ }
+
+ function loadGuestbookComments() {
+ var box = byId("guestbook-comments-inline");
+ if (!box) return;
+ box.innerHTML = '<div class="retro-loading">Loading recent comments...</div>';
+ fetch("/api/guestbook-recent.php?limit=6")
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
+ .then(function (data) {
+ var entries = data && data.entries ? data.entries : [];
+ box.textContent = "";
+ if (!entries.length) {
+ var empty = document.createElement("p");
+ empty.className = "muted";
+ empty.textContent = "No comments yet — be the first to sign!";
+ box.appendChild(empty);
+ return;
+ }
+ entries.forEach(function (row) {
+ var art = document.createElement("article");
+ art.className = "guestbook-entry";
+ var strong = document.createElement("strong");
+ strong.textContent = row.name || "Anonymous";
+ art.appendChild(strong);
+ if (row.created_at) {
+ var time = document.createElement("time");
+ time.textContent = row.created_at;
+ time.setAttribute("datetime", row.created_at);
+ art.appendChild(time);
+ }
+ var p = document.createElement("p");
+ p.textContent = row.message || "";
+ art.appendChild(p);
+ box.appendChild(art);
+ });
+ })
+ .catch(function () {
+ box.innerHTML = '<p class="muted">Could not load comments. <a href="/guestbook.php">Open guestbook</a></p>';
+ });
+ }
+
+ var gbReload = byId("guestbook-comments-reload");
+ if (gbReload) {
+ gbReload.addEventListener("click", loadGuestbookComments);
+ loadGuestbookComments();
+ }
+
+ document.querySelectorAll(".iframe-clip iframe, .vibe-embed iframe").forEach(function (frame) {
+ var clip = frame.closest(".iframe-clip") || frame.closest(".vibe-embed");
+ if (!clip) return;
+ clip.classList.add("is-loading");
+ frame.addEventListener("load", function () {
+ clip.classList.remove("is-loading");
+ }, { once: true });
+ });
+
+ /* ========================================================================
+ Home page: Last.fm live refresh (The "Pro" Way)
+ ======================================================================== */
+ const lastfmList = byId("lastfm-list");
+ const lastfmNow = byId("lastfm-now");
+ if (lastfmList || lastfmNow) {
+ let lastDataStr = "";
+
+ function renderNowPlaying(tracks) {
+ if (!lastfmNow) return;
+ lastfmNow.textContent = "";
+ var current = null;
+ if (Array.isArray(tracks)) {
+ for (var i = 0; i < tracks.length; i++) {
+ if (tracks[i].nowplaying) { current = tracks[i]; break; }
+ }
+ if (!current && tracks[0]) current = tracks[0];
+ }
+ if (!current) {
+ var fallback = document.createElement("p");
+ fallback.className = "muted";
+ fallback.innerHTML = 'Nothing scrobbling — <a href="https://www.last.fm/user/SillyLaird" target="_blank" rel="noopener noreferrer">Last.fm profile</a>';
+ lastfmNow.appendChild(fallback);
+ return;
+ }
+ var card = document.createElement("a");
+ card.className = "lastfm-track lastfm-track--featured";
+ card.href = current.url || "https://www.last.fm/user/SillyLaird";
+ card.target = "_blank";
+ card.rel = "noopener noreferrer";
+ if (current.image) {
+ var wrap = document.createElement("span");
+ wrap.className = "lastfm-image-wrap";
+ var img = document.createElement("img");
+ img.src = current.image;
+ img.alt = (current.artist || "") + " — " + (current.name || "track");
+ img.loading = "lazy";
+ img.decoding = "async";
+ wrap.appendChild(img);
+ card.appendChild(wrap);
+ }
+ var info = document.createElement("span");
+ info.className = "lastfm-info";
+ var status = document.createElement("span");
+ status.className = "lastfm-status" + (current.nowplaying ? " is-playing" : "");
+ status.textContent = current.nowplaying ? "Now playing" : "Last scrobbled";
+ var title = document.createElement("span");
+ title.className = "lastfm-title";
+ title.textContent = current.name || "Unknown track";
+ var artist = document.createElement("span");
+ artist.className = "lastfm-artist";
+ artist.textContent = current.artist || "";
+ info.appendChild(status);
+ info.appendChild(title);
+ info.appendChild(artist);
+ card.appendChild(info);
+ lastfmNow.appendChild(card);
+ }
+
+ function updateLastFM() {
+ fetch("/api/lastfm.php")
+ .then(res => {
+ if (!res.ok) throw new Error("API error: " + res.status);
+ return res.json();
+ })
+ .then(tracks => {
+ if (tracks.error) throw new Error(tracks.message || "Unknown proxy error");
+
+ // Only update DOM if the data has changed
+ const currentDataStr = JSON.stringify(tracks);
+ if (currentDataStr === lastDataStr) return;
+ lastDataStr = currentDataStr;
+
+ renderNowPlaying(tracks);
+
+ if (!lastfmList) return;
+ var ul = document.createElement("ul");
+ ul.className = "lastfm-text-list";
+ if (Array.isArray(tracks) && tracks.length > 0) {
+ tracks.forEach(function (track) {
+ var li = document.createElement("li");
+ if (track.nowplaying) {
+ var strong = document.createElement("strong");
+ strong.textContent = "\u25B6 Now playing: ";
+ li.appendChild(strong);
+ }
+ var a = document.createElement("a");
+ a.href = track.url;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ a.textContent = track.name;
+ li.appendChild(a);
+ li.appendChild(document.createTextNode(" \u2014 " + track.artist));
+ ul.appendChild(li);
+ });
+ } else {
+ var empty = document.createElement("li");
+ empty.className = "muted";
+ empty.textContent = "No tracks found.";
+ ul.appendChild(empty);
+ }
+ lastfmList.textContent = "";
+ lastfmList.appendChild(ul);
+ lastfmList.dataset.loaded = "1";
+ if (lastfmNow) lastfmNow.dataset.loaded = "1";
+ })
+ .catch(err => {
+ console.error("Last.fm Widget Error:", err);
+ if (lastfmNow && !lastfmNow.dataset.loaded) {
+ lastfmNow.dataset.loaded = "1";
+ lastfmNow.innerHTML = '<p class="muted"><a href="https://www.last.fm/user/SillyLaird" target="_blank" rel="noopener noreferrer">Last.fm profile</a></p>';
+ }
+ if (lastfmList && !lastfmList.dataset.loaded) {
+ lastfmList.dataset.loaded = "1";
+ lastfmList.innerHTML = '<p class="muted"><a href="https://www.last.fm/user/SillyLaird" target="_blank" rel="noopener noreferrer">View recent tracks on Last.fm ↗</a></p>';
+ }
+ });
+ }
+
+ // Defer the first fetch until the widget scrolls into view (saves a
+ // third-party request on initial paint — matters on slow/Tor connections).
+ let refreshTimer = null;
+ function startLastFM() {
+ if (refreshTimer) return;
+ updateLastFM();
+ refreshTimer = setInterval(updateLastFM, 30000);
+ }
+ if ("IntersectionObserver" in window) {
+ const io = new IntersectionObserver(function (entries) {
+ if (entries.some(function (e) { return e.isIntersecting; })) {
+ io.disconnect();
+ startLastFM();
+ }
+ }, { rootMargin: "200px" });
+ io.observe(lastfmList || lastfmNow);
+ } else {
+ startLastFM();
+ }
+
+ // Manual refresh button
+ const refreshBtn = byId("lastfm-refresh");
+ if (refreshBtn) {
+ refreshBtn.addEventListener("click", () => {
+ refreshBtn.textContent = "Updating...";
+ refreshBtn.disabled = true;
+ updateLastFM();
+ setTimeout(() => {
+ refreshBtn.textContent = "Refresh";
+ refreshBtn.disabled = false;
+ }, 500);
+ });
+ }
+ }
+
+ /* ========================================================================
+ Footer helpers
+ ======================================================================== */
+ // Footer year: local time only.
+ (function () {
+ const el = byId("copyright-year");
+ if (!el) return;
+
+ el.textContent = String(new Date().getFullYear());
+ })();
+
+ // Footer last-updated: based on document.lastModified.
+ (function () {
+ const el = byId("last-updated");
+ if (!el) return;
+
+ const raw = document.lastModified;
+ const date = raw ? new Date(raw) : null;
+ if (!date || Number.isNaN(date.getTime())) return;
+
+ const iso = date.toISOString().slice(0, 10);
+ el.setAttribute("datetime", iso);
+ el.textContent = iso;
+ })();
+
+ /* ========================================================================
+ Force red text in same-origin iframes
+ ======================================================================== */
+ var _gbFrameIds = new Set(["guestbook-form-iframe", "guestbook-comments-iframe"]);
+
+ function forceIframeColors(frame) {
+ if (_gbFrameIds.has(frame.id)) return;
+ try {
+ var doc = frame.contentDocument || frame.contentWindow.document;
+ if (!doc) return;
+ if (doc.getElementById('forced-colors-style')) return;
+
+ var style = doc.createElement('style');
+ style.id = 'forced-colors-style';
+ style.textContent =
+ ':root { --ink: #8b0000; --bg: #000000; }' +
+ '* { color: #8b0000 !important; }' +
+ 'a { color: #8b0000 !important; text-decoration: underline; }' +
+ 'body { background-color: var(--bg, #000) !important; color: #8b0000 !important; }';
+ doc.head.appendChild(style);
+ } catch (e) {
+ // Cross-domain frames cannot be styled
+ }
+ }
+
+ function attachIframeColorForcing() {
+ document.querySelectorAll('iframe').forEach(function (frame) {
+ if (frame._colorForced) return;
+ frame._colorForced = true;
+ frame.addEventListener('load', function () { forceIframeColors(frame); });
+ // Also try immediately in case already loaded
+ forceIframeColors(frame);
+ });
+ }
+
+ window.addEventListener('load', attachIframeColorForcing);
+ document.addEventListener('DOMContentLoaded', attachIframeColorForcing);
+
+ /* ========================================================================
+ Global Header Search (unified search w/ engine picker)
+ Mirrors /startpage/ behavior; uses gh-* IDs to avoid collisions.
+ ======================================================================== */
+ var GH_SEARCH_ENGINES = [
+ { key: 'site', label: '⌂', title: 'This site', placeholder: 'Search pages on sillylaird.ca…', action: '#', param: 'q', local: true },
+ { key: 'google', label: 'G', title: 'Google Search', placeholder: 'Search Google...', action: 'https://www.google.ca/search', param: 'q' },
+ { key: 'youtube', label: 'YT', title: 'YouTube Search', placeholder: 'Search YouTube...', action: 'https://www.youtube.com/results', param: 'search_query' },
+ { key: 'sillylaird', label: 'SL', title: 'SillyLaird Search', placeholder: 'Search SillyLaird...', action: 'https://search.sillylaird.ca/search', param: 'q' },
+ { key: '4get', label: '4G', title: '4get Search', placeholder: 'Search 4get...', action: 'https://4get.sillylaird.ca/web', param: 's' },
+ { key: 'gemini', label: 'GM', title: 'Gemini Search', placeholder: 'Ask Gemini...', action: '/gemini-search.php', param: 'q' }
+ ];
+ var GH_SEARCH_KEY = 'siteHeaderSearchEngine_v1';
+
+ function initGlobalHeaderSearch() {
+ var form = byId('gh-search-form');
+ var input = byId('gh-search-input');
+ var toggle = byId('gh-search-engine-toggle');
+ var menu = byId('gh-search-engine-menu');
+ if (!form || !input || !toggle || !menu) return;
+ if (toggle._ghInit) return;
+ toggle._ghInit = true;
+
+ var saved = null;
+ try { saved = localStorage.getItem(GH_SEARCH_KEY); } catch (_) {}
+ var idx = GH_SEARCH_ENGINES.findIndex(function (e) { return e.key === saved; });
+ if (idx < 0) idx = GH_SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; });
+ if (idx < 0) idx = 0;
+
+ GH_SEARCH_ENGINES.forEach(function (e, i) {
+ var li = document.createElement('li');
+ li.setAttribute('role', 'option');
+ li.dataset.index = String(i);
+ var icon = document.createElement('span');
+ icon.className = 'engine-icon';
+ icon.textContent = e.label;
+ var name = document.createElement('span');
+ name.className = 'engine-name';
+ name.textContent = e.title;
+ li.appendChild(icon);
+ li.appendChild(name);
+ li.addEventListener('click', function () {
+ idx = i;
+ apply(idx);
+ closeMenu();
+ input.focus();
+ });
+ menu.appendChild(li);
+ });
+
+ function apply(i) {
+ var e = GH_SEARCH_ENGINES[i];
+ form.setAttribute('action', e.local ? '#' : e.action);
+ if (e.local) {
+ form.removeAttribute('target');
+ } else {
+ form.setAttribute('target', '_blank');
+ form.setAttribute('rel', 'noopener noreferrer');
+ }
+ input.setAttribute('name', e.local ? 'site-q' : e.param);
+ input.setAttribute('placeholder', e.placeholder);
+ toggle.textContent = '';
+ toggle.appendChild(document.createTextNode(e.label + ' '));
+ var caret = document.createElement('span');
+ caret.className = 'search-engine-caret';
+ caret.textContent = '▾';
+ toggle.appendChild(caret);
+ toggle.setAttribute('title', 'Search engine: ' + e.title);
+ Array.prototype.forEach.call(menu.children, function (li, li_i) {
+ li.setAttribute('aria-selected', li_i === i ? 'true' : 'false');
+ });
+ try { localStorage.setItem(GH_SEARCH_KEY, e.key); } catch (_) {}
+ }
+
+ function openMenu() {
+ menu.hidden = false;
+ toggle.setAttribute('aria-expanded', 'true');
+ }
+ function closeMenu() {
+ menu.hidden = true;
+ toggle.setAttribute('aria-expanded', 'false');
+ }
+
+ toggle.addEventListener('click', function (ev) {
+ ev.stopPropagation();
+ if (menu.hidden) openMenu(); else closeMenu();
+ });
+
+ document.addEventListener('click', function (ev) {
+ if (menu.hidden) return;
+ if (ev.target === toggle || toggle.contains(ev.target)) return;
+ if (menu.contains(ev.target)) return;
+ closeMenu();
+ });
+
+ document.addEventListener('keydown', function (ev) {
+ if (ev.key === 'Escape' && !menu.hidden) closeMenu();
+ });
+
+ var siteHits = byId('site-search-hits');
+ var siteIndex = null;
+
+ function loadSiteIndex() {
+ if (siteIndex) return Promise.resolve(siteIndex);
+ return fetch('/assets/data/site-index.json')
+ .then(function (r) { return r.json(); })
+ .then(function (j) { siteIndex = Array.isArray(j) ? j : []; return siteIndex; })
+ .catch(function () { siteIndex = []; return siteIndex; });
+ }
+
+ function renderSiteHits(q) {
+ if (!siteHits) return;
+ var query = (q || '').trim().toLowerCase();
+ if (!query) { siteHits.hidden = true; siteHits.textContent = ''; return; }
+ loadSiteIndex().then(function (pages) {
+ var hits = pages.filter(function (p) {
+ var hay = (p.title + ' ' + p.url + ' ' + (p.tags || '')).toLowerCase();
+ return hay.indexOf(query) !== -1;
+ }).slice(0, 8);
+ siteHits.textContent = '';
+ if (!hits.length) { siteHits.hidden = true; return; }
+ hits.forEach(function (p) {
+ var li = document.createElement('li');
+ var a = document.createElement('a');
+ a.href = p.url;
+ if (/^https?:\/\//.test(p.url)) {
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ }
+ a.textContent = p.title;
+ li.appendChild(a);
+ siteHits.appendChild(li);
+ });
+ siteHits.hidden = false;
+ });
+ }
+
+ input.addEventListener('input', function () {
+ if (GH_SEARCH_ENGINES[idx] && GH_SEARCH_ENGINES[idx].local) {
+ renderSiteHits(input.value);
+ } else if (siteHits) {
+ siteHits.hidden = true;
+ }
+ });
+
+ form.addEventListener('submit', function (ev) {
+ if (GH_SEARCH_ENGINES[idx] && GH_SEARCH_ENGINES[idx].local) {
+ ev.preventDefault();
+ var first = siteHits && siteHits.querySelector('a');
+ if (first) first.click();
+ else renderSiteHits(input.value);
+ }
+ });
+
+ apply(idx);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initGlobalHeaderSearch);
+ } else {
+ initGlobalHeaderSearch();
+ }
+
+ (function initRadioWidget() {
+ var box = byId('radio-widget');
+ if (!box) return;
+ function render(title) {
+ box.textContent = '';
+ var p = document.createElement('p');
+ if (title) {
+ p.className = 'radio-now-line';
+ var strong = document.createElement('strong');
+ strong.textContent = 'Radio: ';
+ p.appendChild(strong);
+ var span = document.createElement('span');
+ span.id = 'radio-nowplaying-title';
+ span.textContent = title;
+ p.appendChild(span);
+ p.appendChild(document.createTextNode(' — '));
+ var a = document.createElement('a');
+ a.href = 'https://radio.sillylaird.ca/';
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ a.textContent = 'Listen live';
+ p.appendChild(a);
+ } else {
+ p.className = 'muted';
+ p.textContent = 'Radio offline — ';
+ var link = document.createElement('a');
+ link.href = 'https://radio.sillylaird.ca/';
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.textContent = 'radio.sillylaird.ca';
+ p.appendChild(link);
+ }
+ box.appendChild(p);
+ }
+ function poll() {
+ fetch('/api/nowplaying.php', { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
+ .then(function (j) {
+ var title = j && j.title ? String(j.title).trim() : '';
+ render(title);
+ })
+ .catch(function () { render(''); });
+ }
+ poll();
+ setInterval(poll, 30000);
+ })();
+
+ (function initLainEgg() {
+ var btn = byId('lain-avatar-btn');
+ if (!btn) return;
+ var secrets = [
+ btn.getAttribute('data-secret-1'),
+ btn.getAttribute('data-secret-2'),
+ btn.getAttribute('data-secret-3'),
+ ].filter(Boolean);
+ var i = 0;
+ btn.addEventListener('click', function () {
+ var msg = secrets[i % secrets.length] || "Let's all love Lain.";
+ i += 1;
+ btn.classList.add('is-lain-pulse');
+ window.setTimeout(function () { btn.classList.remove('is-lain-pulse'); }, 600);
+ var toast = document.createElement('div');
+ toast.className = 'lain-toast';
+ toast.setAttribute('role', 'status');
+ toast.textContent = msg;
+ document.body.appendChild(toast);
+ window.setTimeout(function () { toast.classList.add('is-visible'); }, 10);
+ window.setTimeout(function () {
+ toast.classList.remove('is-visible');
+ window.setTimeout(function () { toast.remove(); }, 350);
+ }, 2800);
+ });
+ })();
+
+ // Per-page visit counter: canonical implementation is visit-counter.js
+ // (nginx injects it network-wide). Fall back here if that script is absent.
+ (function initVisitCounter() {
+ if (window.__slVisitCounterInit) return;
+ if (document.querySelector('script[src*="visit-counter.js"]')) return;
+ var s = document.createElement('script');
+ // Always load from www so sibling hosts (blog/radio/…) resolve correctly
+ // even when this file itself was loaded cross-origin via nav.js.
+ s.src = 'https://www.sillylaird.ca/assets/js/visit-counter.js?v=2026080403';
+ s.defer = true;
+ document.head.appendChild(s);
+ })();
+
+ // Scroll-to-top: canonical implementation is scroll-to-top.js (nginx injects
+ // it network-wide alongside the visit counter). Fall back if absent.
+ (function initScrollToTop() {
+ if (window.__slScrollTopInit) return;
+ if (document.querySelector('script[src*="scroll-to-top.js"]')) return;
+ var s = document.createElement('script');
+ s.src = 'https://www.sillylaird.ca/assets/js/scroll-to-top.js?v=20260821b';
+ s.defer = true;
+ document.head.appendChild(s);
+ })();
+
+ (function initSectionDraw() {
+ var titles = document.querySelectorAll('.section-accent-title, .network-subtitle');
+ var sections = document.querySelectorAll('.home-content > section');
+ var targets = [];
+ titles.forEach(function (el) { targets.push(el); });
+ sections.forEach(function (el) { targets.push(el); });
+ if (!targets.length) return;
+
+ function markDrawn(el) {
+ el.classList.add('is-drawn');
+ }
+
+ if (!('IntersectionObserver' in window) ||
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ targets.forEach(markDrawn);
+ return;
+ }
+
+ var io = new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (!entry.isIntersecting) return;
+ markDrawn(entry.target);
+ io.unobserve(entry.target);
+ });
+ }, { threshold: 0.35, rootMargin: '0px 0px -8% 0px' });
+
+ targets.forEach(function (el) { io.observe(el); });
+ })();
+
+ (function initNetworkAccordion() {
+ var items = document.querySelectorAll('.network-collapsible');
+ if (!items.length) return;
+ var mq = window.matchMedia('(max-width: 600px)');
+
+ function sync() {
+ var mobile = mq.matches;
+ items.forEach(function (el, i) {
+ el.open = mobile ? i === 0 : true;
+ });
+ }
+
+ sync();
+ if (typeof mq.addEventListener === 'function') {
+ mq.addEventListener('change', sync);
+ } else if (typeof mq.addListener === 'function') {
+ mq.addListener(sync);
+ }
+ })();
+
+ /* ========================================================================
+ Vibe facade: click-to-load embeds (no provider request until clicked)
+
+ The player iframe is created synchronously inside the click handler so
+ Chrome allows autoplay (gesture-driven). State is read straight from each
+ provider's postMessage protocol, so when the viewer pauses we show a
+ "paused" overlay that mirrors the load facade but with a pause glyph.
+
+ One provider entry per supported site. `state(data, iframe)` maps a raw
+ message to 'paused' | 'playing' | null. Bilibili exposes no cross-origin
+ pause event, so it has no entry and falls back to a plain autoplay iframe.
+ ======================================================================== */
+
+ function vibePost(iframe, msg, origin) {
+ try {
+ iframe.contentWindow.postMessage(typeof msg === 'string' ? msg : JSON.stringify(msg), origin || '*');
+ } catch (_) {}
+ }
+
+ function withParam(url, extra) {
+ return url + (url.indexOf('?') > -1 ? '&' : '?') + extra;
+ }
+
+ // Pause overlay — same menu as the load facade. Mode 'paused' shows a pause
+ // glyph + resume copy; mode 'ended' shows a replay glyph + replay copy.
+ function setOverlayMode(overlay, mode) {
+ if (mode === 'ended') {
+ overlay.classList.add('vibe-facade-ended');
+ overlay.setAttribute('aria-label', 'Replay the video');
+ overlay.innerHTML =
+ '<span class="vibe-facade-play" aria-hidden="true">↻</span>' +
+ '<span class="vibe-facade-text">Replay<br><small>Click to watch again</small></span>';
+ } else {
+ overlay.classList.remove('vibe-facade-ended');
+ overlay.setAttribute('aria-label', 'Resume the video');
+ overlay.innerHTML =
+ '<span class="vibe-facade-play" aria-hidden="true">⏸</span>' +
+ '<span class="vibe-facade-text">Paused<br><small>Click to resume</small></span>';
+ }
+ }
+
+ function makePauseOverlay() {
+ var overlay = document.createElement('button');
+ overlay.type = 'button';
+ overlay.className = 'vibe-facade vibe-facade-paused';
+ overlay.hidden = true;
+ setOverlayMode(overlay, 'paused');
+ return overlay;
+ }
+
+ var VIBE_PROVIDERS = {
+ youtube: {
+ match: /youtube(-nocookie)?\.com\/embed\//,
+ origin: /^https?:\/\/(www\.)?youtube(-nocookie)?\.com$/,
+ // youtube.com (not -nocookie): the postMessage API replies from
+ // youtube.com. Privacy is already moot once the user has clicked.
+ src: function (e) { return withParam(e.replace('youtube-nocookie.com', 'youtube.com'), 'autoplay=1&enablejsapi=1'); },
+ subscribe: function (f) {
+ vibePost(f, { event: 'listening', id: 1, channel: 'widget' });
+ vibePost(f, { event: 'command', func: 'addEventListener', args: ['onStateChange'] });
+ },
+ play: function (f) { vibePost(f, { event: 'command', func: 'playVideo', args: [] }); },
+ state: function (d) {
+ var s = null;
+ if (d.event === 'onStateChange') s = d.info;
+ else if (d.event === 'infoDelivery' && d.info && typeof d.info.playerState === 'number') s = d.info.playerState;
+ if (s === 0) return 'ended'; // 0 = ended → show replay button
+ if (s === 2) return 'paused'; // 2 = paused
+ if (s === 1 || s === 3) return 'playing'; // 1 = playing, 3 = buffering
+ return null;
+ }
+ },
+
+ vimeo: {
+ match: /player\.vimeo\.com\/video\//,
+ origin: /^https?:\/\/player\.vimeo\.com$/,
+ src: function (e) { return withParam(e, 'autoplay=1'); },
+ subscribe: function (f) {
+ vibePost(f, { method: 'addEventListener', value: 'play' }, 'https://player.vimeo.com');
+ vibePost(f, { method: 'addEventListener', value: 'pause' }, 'https://player.vimeo.com');
+ },
+ play: function (f) { vibePost(f, { method: 'play' }, 'https://player.vimeo.com'); },
+ state: function (d, f) {
+ if (d.event === 'ready') { this.subscribe(f); return null; } // (re)subscribe once ready
+ if (d.event === 'pause') return 'paused';
+ if (d.event === 'play' || d.event === 'playing') return 'playing';
+ return null;
+ }
+ },
+
+ dailymotion: {
+ match: /dailymotion\.com\/player/,
+ origin: /^https?:\/\/(geo\.)?dailymotion\.com$/,
+ src: function (e) { return withParam(e, 'autoplay=1'); },
+ play: function (f) {
+ vibePost(f, { command: 'play', parameters: [] });
+ vibePost(f, { method: 'play' });
+ },
+ state: function (d) {
+ var ev = d.event;
+ if (ev === 'pause' || ev === 'video_pause') return 'paused';
+ if (ev === 'play' || ev === 'playing' || ev === 'video_start') return 'playing';
+ return null;
+ }
+ },
+
+ niconico: {
+ match: /embed\.nicovideo\.jp\/watch\//,
+ origin: /^https?:\/\/embed\.nicovideo\.jp$/,
+ // jsapi=1&playerId=1 turns on Niconico's postMessage control API.
+ src: function (e) { return withParam(e, 'jsapi=1&playerId=1&autoplay=1'); },
+ play: function (f) {
+ vibePost(f, { sourceConnectorType: 1, playerId: f._vibeNicoId || '1', eventName: 'play' }, 'https://embed.nicovideo.jp');
+ },
+ state: function (d, f) {
+ if (d.playerId != null) f._vibeNicoId = d.playerId; // captured for play()
+ if (d.eventName === 'statusChange' || d.eventName === 'playerStatusChange') {
+ var ps = d.data && d.data.playerStatus;
+ if (ps === 3) return 'paused'; // 3 = pause
+ if (ps === 2) return 'playing'; // 2 = play
+ }
+ return null;
+ }
+ }
+ };
+
+ function vibeProviderFor(embed) {
+ for (var k in VIBE_PROVIDERS) {
+ if (VIBE_PROVIDERS.hasOwnProperty(k) && VIBE_PROVIDERS[k].match.test(embed)) return VIBE_PROVIDERS[k];
+ }
+ return null;
+ }
+
+ function loadProviderWithOverlay(wrap, facadeBtn, embed, prov) {
+ var f = document.createElement('iframe');
+ f.src = prov.src(embed);
+ f.title = 'Current vibe — video';
+ f.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
+ f.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen');
+ f.setAttribute('allowfullscreen', '');
+ facadeBtn.replaceWith(f);
+
+ var overlay = makePauseOverlay();
+ wrap.appendChild(overlay);
+
+ overlay.addEventListener('click', function () {
+ overlay.hidden = true;
+ prov.play(f);
+ });
+
+ f.addEventListener('load', function () { if (prov.subscribe) prov.subscribe(f); });
+
+ window.addEventListener('message', function (ev) {
+ if (prov.origin && !prov.origin.test(ev.origin)) return;
+ var data = ev.data;
+ if (typeof data === 'string') {
+ try {
+ data = JSON.parse(data);
+ } catch (_) {
+ if (data.indexOf('=') === -1) return; // some players send querystring strings
+ var o = {};
+ data.split('&').forEach(function (kv) {
+ var p = kv.split('=');
+ o[decodeURIComponent(p[0])] = decodeURIComponent(p[1] || '');
+ });
+ data = o;
+ }
+ }
+ if (!data || typeof data !== 'object') return;
+ var st = prov.state(data, f);
+ if (st === 'ended') { setOverlayMode(overlay, 'ended'); overlay.hidden = false; }
+ else if (st === 'paused') { setOverlayMode(overlay, 'paused'); overlay.hidden = false; }
+ else if (st === 'playing') overlay.hidden = true;
+ });
+ }
+
+ // Direct linked media: native <video>/<audio> get the same pause overlay,
+ // driven by the element's own play/pause events (no postMessage). Audio has
+ // no video surface, so it gets a compact (icon + "Paused") variant.
+ function attachNativePauseOverlay(media, compact) {
+ var wrap;
+ if (compact) {
+ // Audio has no positioned container — wrap it so the overlay can sit on top.
+ wrap = media.parentNode;
+ if (!wrap || !wrap.classList.contains('vibe-audio-wrap')) {
+ wrap = document.createElement('div');
+ wrap.className = 'vibe-audio-wrap';
+ media.parentNode.insertBefore(wrap, media);
+ wrap.appendChild(media);
+ }
+ } else {
+ wrap = media.parentNode; // .vibe-embed (position:relative)
+ }
+ if (!wrap || wrap._vibePauseDone) return;
+ wrap._vibePauseDone = true;
+
+ var overlay = makePauseOverlay();
+ if (compact) overlay.classList.add('vibe-facade-compact');
+ wrap.appendChild(overlay);
+
+ overlay.addEventListener('click', function () {
+ overlay.hidden = true;
+ var p = media.play();
+ if (p && p.catch) p.catch(function () {});
+ });
+ // Only after playback has begun — don't cover the not-yet-started media.
+ media.addEventListener('pause', function () { if (!media.ended) { setOverlayMode(overlay, 'paused'); overlay.hidden = false; } });
+ media.addEventListener('play', function () { overlay.hidden = true; });
+ media.addEventListener('ended', function () { setOverlayMode(overlay, 'ended'); overlay.hidden = false; });
+ }
+
+ function scanNativeVibeMedia() {
+ var vids = document.querySelectorAll('.vibe-embed > video');
+ for (var i = 0; i < vids.length; i++) attachNativePauseOverlay(vids[i], false);
+ var auds = document.querySelectorAll('.vibe-media-audio');
+ for (var j = 0; j < auds.length; j++) attachNativePauseOverlay(auds[j], true);
+ }
+ scanNativeVibeMedia();
+ document.addEventListener('DOMContentLoaded', scanNativeVibeMedia);
+
+ document.addEventListener('click', function (ev) {
+ var b = ev.target.closest ? ev.target.closest('.vibe-facade') : null;
+ if (!b || !b.dataset.embed) return;
+ var embed = b.dataset.embed;
+ var wrap = b.parentNode; // .vibe-embed (position:relative)
+
+ // Supported provider → load with pause overlay.
+ var prov = wrap ? vibeProviderFor(embed) : null;
+ if (prov) {
+ loadProviderWithOverlay(wrap, b, embed, prov);
+ return;
+ }
+
+ // Bilibili / anything else → plain autoplay iframe.
+ var src = withParam(embed, 'autoplay=1');
+ var f = document.createElement('iframe');
+ f.src = src;
+ f.title = 'Current vibe — video';
+ f.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
+ f.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen');
+ f.setAttribute('allowfullscreen', '');
+ b.replaceWith(f);
+ });
+
+})();