Lumi/plugins/now_playing/public/render.js
2026-07-24 14:44:27 +02:00

209 lines
8.1 KiB
JavaScript

(() => {
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 = '<span data-position>0:00</span><div class="np-track"><div class="np-progress" data-progress></div></div><span data-duration></span>';
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();
});
})();