(() => { const root = document.getElementById("now-playing-root"); const initial = JSON.parse(root?.dataset.state || "{}"); const token = root?.dataset.token || ""; let state = initial; let card = null; let hideTimer = null; let progressTimer = null; let lastTrackKey = ""; let lastConfigSignature = ""; let refreshRequest = null; let fallbackTimer = null; const escapeText = (value) => String(value ?? ""); const formatTime = (milliseconds) => { const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000)); const minutes = Math.floor(seconds / 60); return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; }; const projectedPosition = () => { const playback = state.playback; if (!playback) return 0; let value = Number(playback.position_ms || 0); if (playback.status === "playing") value += Math.max(0, Date.now() - Number(playback.reported_at || Date.now())) * Number(playback.rate || 1); return Math.min(Number(playback.duration_ms || value), value); }; function backgroundColors(config, track) { const coverBased = String(config.background_mode || "").startsWith("cover-"); return { primary: coverBased ? track.color_primary : config.custom_color_primary, secondary: coverBased ? track.color_secondary : config.custom_color_secondary, solid: String(config.background_mode || "").endsWith("-solid") }; } function buildCard() { const config = state.config || {}; const track = state.track || {}; const colors = backgroundColors(config, track); const element = document.createElement("article"); element.className = `np-card scope-${String(config.transparency_scope || "background").replaceAll("_", "-")}${colors.solid ? " background-solid" : ""}`; element.dataset.animation = config.entry_animation || "fade"; element.style.setProperty("--np-primary", colors.primary || "#1f2937"); element.style.setProperty("--np-secondary", colors.secondary || colors.primary || "#111827"); element.style.setProperty("--np-opacity", String(config.opacity ?? .92)); element.style.setProperty("--np-radius", `${Number(config.corner_radius || 18)}px`); element.style.setProperty("--np-scale", String(config.scale || 1)); element.style.setProperty("--np-duration", `${Number(config.animation_ms || 450)}ms`); if (config.show_cover && track.cover_url) { const cover = document.createElement("img"); cover.className = "np-cover"; cover.src = track.cover_url; cover.alt = ""; element.appendChild(cover); } const content = document.createElement("div"); content.className = "np-content"; if (config.show_provider && track.provider) { const provider = document.createElement("span"); provider.className = "np-provider"; provider.textContent = escapeText(track.provider); content.appendChild(provider); } if (config.show_title) { const title = document.createElement("strong"); title.className = "np-title"; title.textContent = escapeText(track.name || "Unknown song"); content.appendChild(title); } if (config.show_artist && track.artist) { const artist = document.createElement("span"); artist.className = "np-artist"; artist.textContent = escapeText(track.artist); content.appendChild(artist); } if ((config.show_album && track.album) || (config.show_release_year && track.release_year)) { const meta = document.createElement("div"); meta.className = "np-meta"; if (config.show_album && track.album) { const album = document.createElement("span"); album.textContent = escapeText(track.album); meta.appendChild(album); } if (config.show_release_year && track.release_year) { const year = document.createElement("span"); year.textContent = escapeText(track.release_year); meta.appendChild(year); } content.appendChild(meta); } if (config.show_timeline && Number(state.playback?.duration_ms || 0) > 0) { const timeline = document.createElement("div"); timeline.className = "np-timeline"; timeline.innerHTML = '0:00
'; content.appendChild(timeline); } element.appendChild(content); return element; } function updateProgress() { if (!card) return; const duration = Number(state.playback?.duration_ms || 0); const position = projectedPosition(); const progress = card.querySelector("[data-progress]"); const positionLabel = card.querySelector("[data-position]"); const durationLabel = card.querySelector("[data-duration]"); if (progress) progress.style.width = `${duration ? Math.min(100, Math.max(0, position / duration * 100)) : 0}%`; if (positionLabel) positionLabel.textContent = formatTime(position); if (durationLabel) durationLabel.textContent = formatTime(duration); } function show(nextState) { const previousState = state; state = nextState || {}; if (!state.available || !state.track) return hide(); const nextTrackKey = state.track.key || ""; const trackChanged = nextTrackKey !== lastTrackKey; const configSignature = JSON.stringify(state.config || {}); const configChanged = configSignature !== lastConfigSignature; const timed = state.config?.display_mode === "timed"; lastTrackKey = nextTrackKey; lastConfigSignature = configSignature; // Ordinary playback and heartbeat updates should only advance the timeline. // Rebuilding the DOM for every delta causes visible jitter and can re-show a // timed card after its configured display window has elapsed. if (!trackChanged && !configChanged) { if (card) updateProgress(); return; } if (!trackChanged && timed && !card) return; clearTimeout(hideTimer); const nextCard = buildCard(); if (card) card.remove(); card = nextCard; root.replaceChildren(card); card.classList.add("is-entering"); card.addEventListener("animationend", () => card?.classList.remove("is-entering"), { once: true }); clearInterval(progressTimer); progressTimer = setInterval(updateProgress, 250); updateProgress(); if (timed && trackChanged) { hideTimer = setTimeout(hide, Math.max(1, Number(state.config.display_seconds || 10)) * 1000); } } function hide() { clearTimeout(hideTimer); clearInterval(progressTimer); if (!card) return; const removing = card; removing.dataset.animation = state.config?.exit_animation || "fade"; removing.classList.remove("is-entering"); removing.classList.add("is-exiting"); const remove = () => { if (card === removing) card = null; removing.remove(); }; removing.addEventListener("animationend", remove, { once: true }); setTimeout(remove, Number(state.config?.animation_ms || 450) + 100); } function refreshState() { if (refreshRequest) return refreshRequest; refreshRequest = fetch(`/plugins/now_playing/render/${encodeURIComponent(token)}/state`, { cache: "no-store", credentials: "omit" }) .then((response) => { if (!response.ok) throw new Error(`Song state request failed (${response.status}).`); return response.json(); }) .then(show) .catch(() => {}) .finally(() => { refreshRequest = null; }); return refreshRequest; } function startFallbackPolling() { if (fallbackTimer) return; refreshState(); fallbackTimer = setInterval(refreshState, 2000); } function stopFallbackPolling() { clearInterval(fallbackTimer); fallbackTimer = null; } show(initial); const events = new EventSource(`/plugins/now_playing/render/${encodeURIComponent(token)}/events`); events.addEventListener("state", (event) => { try { show(JSON.parse(event.data)); } catch {} }); events.addEventListener("changed", refreshState); events.addEventListener("open", stopFallbackPolling); events.addEventListener("error", startFallbackPolling); window.addEventListener("beforeunload", () => { stopFallbackPolling(); events.close(); }); })();