/* * nowplaying — wires visualiser.js to Home Assistant's media_player state. * * HA is the source because it is the one thing every endpoint here can reach and it * already knows what is playing in every room. That also means a panel can show ANOTHER * room's music, which is the normal case and the reason the synthetic tier exists. */ "use strict"; const params = new URLSearchParams(location.search); const HA = (params.get("ha") || "").replace(/\/$/, ""); const TOKEN = params.get("token") || ""; const ENTITY = params.get("entity") || ""; // How often to ask HA. Two seconds is plenty: the ring animates locally from position, // so polling only has to catch track CHANGES, not drive the animation. const POLL_MS = Number(params.get("poll") || 2000); const $ = (id) => document.getElementById(id); if (!HA || !TOKEN || !ENTITY) { $("error").hidden = false; $("error").textContent = "Not configured — needs ?ha=&token=&entity= in the URL. See nowplaying.html."; throw new Error("nowplaying: missing ?ha=/&token=/&entity="); } const visualiser = new MediaVisualiser($("ring"), { background: "#0a0510" }); visualiser.start(); let currentTrackKey = ""; // Position is interpolated between polls rather than asked for constantly: HA reports // media_position with the timestamp it was measured at, so the honest current position // is that value plus the time since. Polling for it instead would make the progress arc // tick in two-second jumps. let positionBase = { ms: 0, at: Date.now(), playing: false }; function haGet(path) { return fetch(`${HA}${path}`, { headers: { Authorization: `Bearer ${TOKEN}` } }).then((res) => { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); }); } function poll() { haGet(`/api/states/${encodeURIComponent(ENTITY)}`) .then((state) => { $("error").hidden = true; render(state); }) .catch((err) => { $("error").hidden = false; $("error").textContent = `Home Assistant: ${err.message}`; }) .finally(() => setTimeout(poll, POLL_MS)); } function render(state) { const a = state.attributes || {}; const playing = state.state === "playing"; const key = `${a.media_title || ""}|${a.media_artist || ""}|${a.entity_picture || ""}`; $("title").textContent = a.media_title || (state.state === "off" ? "Nothing playing" : "—"); $("artist").textContent = [a.media_artist, a.media_album_name].filter(Boolean).join(" — "); // HA gives media_position together with media_position_updated_at, which is what makes // interpolation correct rather than a guess. const updatedAt = a.media_position_updated_at ? Date.parse(a.media_position_updated_at) : Date.now(); positionBase = { ms: (a.media_position || 0) * 1000, at: updatedAt, playing }; if (key !== currentTrackKey) { currentTrackKey = key; loadArtwork(a.entity_picture); loadLyrics(a); } visualiser.setTrack({ duration_ms: (a.media_duration || 0) * 1000, position_ms: currentPosition(), playing, // Some sources expose a tempo; most do not. Null means the synthetic tier uses its // default rate rather than pretending to know the BPM. tempo: a.media_tempo || null, }); $("tier-note").textContent = visualiser.tier === "reactive" ? "" : "visual rhythm — not an audio analysis"; } function currentPosition() { if (!positionBase.playing) return positionBase.ms; return positionBase.ms + (Date.now() - positionBase.at); } // Keep the arc and the lyric highlight moving between polls. setInterval(() => { visualiser.setTrack({ position_ms: currentPosition() }); highlightLyric(); }, 250); function loadArtwork(picture) { const cover = $("cover"); if (!picture) { cover.hidden = true; $("cover-fallback").hidden = false; return; } const url = picture.startsWith("http") ? picture : `${HA}${picture}`; const image = new Image(); // Needed for paletteFrom(): without it the canvas is tainted and getImageData throws, // which the SDK handles by keeping the default palette — this just makes the good // path possible when HA sends the header. image.crossOrigin = "anonymous"; image.onload = () => { cover.src = image.src; cover.hidden = false; $("cover-fallback").hidden = true; visualiser.setArtwork(image); }; image.onerror = () => { cover.hidden = true; $("cover-fallback").hidden = false; }; image.src = url; } // --- lyrics --------------------------------------------------------------------------- // ABSENT IS THE NORMAL CASE. Most tracks in most libraries have no lyrics, so the layout // is designed for "no lyrics" with lyrics as the addition — not a gap where they would be. let lyricLines = []; let lyricPlain = ""; function loadLyrics(attributes) { lyricLines = []; lyricPlain = ""; const raw = attributes.media_lyrics || attributes.lyrics || ""; if (!raw) { $("lyrics").hidden = true; return; } lyricLines = MediaVisualiser.parseLrc(raw); if (lyricLines.length) { $("lyrics-lines").innerHTML = lyricLines .map((l, i) => `
${escapeHtml(l.text)}
`) .join(""); } else { // Plain text: shown, never auto-scrolled at a guessed rate. A guessed scroll is // wrong within ten seconds and stays wrong for the rest of the song. lyricPlain = raw; $("lyrics-lines").innerHTML = `${escapeHtml(raw)}`;
}
$("lyrics").hidden = false;
}
function highlightLyric() {
if (!lyricLines.length) return;
const index = MediaVisualiser.currentLyricIndex(lyricLines, currentPosition());
const lines = $("lyrics-lines").children;
for (let i = 0; i < lines.length; i++) {
lines[i].classList.toggle("current", Number(lines[i].dataset.i) === index);
}
const active = $("lyrics-lines").querySelector(".current");
if (active) active.scrollIntoView({ block: "center", behavior: "smooth" });
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
);
}
poll();