209 lines
8.1 KiB
JavaScript
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();
|
|
});
|
|
})();
|