174 lines
6.0 KiB
JavaScript
174 lines
6.0 KiB
JavaScript
/*
|
|
* 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) => `<p data-i="${i}">${escapeHtml(l.text)}</p>`)
|
|
.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 = `<pre class="plain">${escapeHtml(raw)}</pre>`;
|
|
}
|
|
$("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();
|