SmartestHome/render/media-visualiser/nowplaying.js

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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
poll();