c.stupid.cat/js/now-playing.js

256 lines
8.5 KiB
JavaScript

const DISCORD_USER_ID = "1464890289922641993";
(function nowPlaying() {
const el = document.getElementById("now-playing");
if (!el) return;
// Stay hidden until configured (keeps the widget invisible on a fresh clone)
if (!DISCORD_USER_ID || DISCORD_USER_ID === "REPLACE_WITH_YOUR_DISCORD_USER_ID") {
return;
}
const artEl = el.querySelector(".np-art");
const labelEl = el.querySelector(".np-label");
const statusLabelEl = el.querySelector(".np-status-label");
const trackEl = el.querySelector(".np-track");
const artistEl = el.querySelector(".np-artist");
const fillEl = el.querySelector(".np-fill");
const curEl = el.querySelector(".np-cur");
const durEl = el.querySelector(".np-dur");
const STATUS_LABELS = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
offline: "Offline",
};
let latest = null; // last presence payload
let progressTimer = null; // 1s ticker while a track is playing
let ws = null;
let heartbeat = null;
let reconnectDelay = 1000;
function fmt(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function clamp(n, lo, hi) {
return Math.min(Math.max(n, lo), hi);
}
// ---- snap album-art colour to the active Catppuccin palette ------------
// The accent vars are read live from CSS, so this follows whichever flavour
// (mocha / macchiato / frappe / latte) is currently active.
const ACCENT_VARS = [
"rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach",
"yellow", "green", "teal", "sky", "saphire", "blue", "lavender",
];
function hexToRgb(hex) {
hex = hex.trim().replace("#", "");
if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
const n = parseInt(hex, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function getThemePalette() {
const cs = getComputedStyle(document.documentElement);
const pal = [];
for (const name of ACCENT_VARS) {
const v = cs.getPropertyValue("--" + name).trim();
if (v.startsWith("#")) {
const [r, g, b] = hexToRgb(v);
pal.push({ name, r, g, b });
}
}
return pal;
}
// Nearest palette swatch using a redmean-weighted distance (closer to how
// the eye judges colour difference than plain RGB distance).
function nearestAccent(r, g, b) {
const pal = getThemePalette();
let best = null, bestD = Infinity;
for (const c of pal) {
const rm = (r + c.r) / 2;
const dr = r - c.r, dg = g - c.g, db = b - c.b;
const d = (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db;
if (d < bestD) { bestD = d; best = c; }
}
return best;
}
// ---- album-art accent colour -------------------------------------------
let lastArtUrl = null;
function applyAccent(url) {
if (!url || url === lastArtUrl) return;
lastArtUrl = url;
const img = new Image();
img.crossOrigin = "anonymous";
img.referrerPolicy = "no-referrer";
img.onload = () => {
try {
const c = document.createElement("canvas");
const size = 16;
c.width = size;
c.height = size;
const ctx = c.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, size, size);
const { data } = ctx.getImageData(0, 0, size, size);
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 125) continue;
// skip near-black/near-white so the tint stays vivid
const lum = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
if (lum < 24 || lum > 235) continue;
r += data[i]; g += data[i + 1]; b += data[i + 2]; count++;
}
if (!count) { resetAccent(); return; }
r = Math.round(r / count); g = Math.round(g / count); b = Math.round(b / count);
// Snap the average album colour to the nearest Catppuccin accent
const near = nearestAccent(r, g, b);
const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`;
el.style.setProperty("--np-accent", rgb);
el.classList.add("has-accent");
// Drive the whole page's accent (nav, badges, name, link hovers…)
document.documentElement.style.setProperty("--accent-rgb", rgb);
} catch (e) {
resetAccent(); // tainted canvas / CORS — fall back to theme colour
}
};
img.onerror = resetAccent;
img.src = url;
}
function resetAccent() {
el.classList.remove("has-accent");
el.style.removeProperty("--np-accent");
// Hand the page's accent back to the active theme's pink
document.documentElement.style.removeProperty("--accent-rgb");
}
// ---- rendering ----------------------------------------------------------
function stopProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null; }
}
function tickProgress(spotify) {
const start = spotify.timestamps && spotify.timestamps.start;
const end = spotify.timestamps && spotify.timestamps.end;
if (!start || !end || end <= start) {
el.classList.remove("has-progress");
return;
}
el.classList.add("has-progress");
const now = Date.now();
const elapsed = clamp(now - start, 0, end - start);
const pct = clamp((elapsed / (end - start)) * 100, 0, 100);
fillEl.style.width = pct + "%";
curEl.textContent = fmt(elapsed);
durEl.textContent = fmt(end - start);
}
function render(d) {
if (!d) return;
const status = d.discord_status || "offline";
el.dataset.status = status;
// Discord status word — always shown (coexists with the track)
statusLabelEl.textContent = STATUS_LABELS[status] || "Offline";
const spotify = d.listening_to_spotify && d.spotify ? d.spotify : null;
if (spotify) {
el.classList.add("is-live");
labelEl.textContent = "Now playing";
trackEl.textContent = spotify.song || "";
artistEl.textContent = spotify.artist || "";
if (spotify.album_art_url) {
artEl.src = spotify.album_art_url;
artEl.style.display = "";
applyAccent(spotify.album_art_url);
} else {
artEl.style.display = "none";
resetAccent();
}
el.href = spotify.track_id
? `https://open.spotify.com/track/${spotify.track_id}`
: "https://open.spotify.com/";
stopProgress();
tickProgress(spotify);
progressTimer = setInterval(() => tickProgress(spotify), 1000);
} else {
// Not listening — just the Discord status (dot + word in the head)
el.classList.remove("is-live", "has-progress");
stopProgress();
resetAccent();
trackEl.textContent = "";
artistEl.textContent = "";
artEl.style.display = "none";
el.href = "https://discord.gg/TransRights";
}
el.hidden = false;
}
// ---- Lanyard websocket --------------------------------------------------
function connect() {
ws = new WebSocket("wss://api.lanyard.rest/socket");
ws.addEventListener("message", (evt) => {
let msg;
try { msg = JSON.parse(evt.data); } catch (e) { return; }
// op 1 = Hello: start heartbeat, then subscribe
if (msg.op === 1) {
const interval = (msg.d && msg.d.heartbeat_interval) || 30000;
if (heartbeat) clearInterval(heartbeat);
heartbeat = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 3 }));
}, interval);
ws.send(JSON.stringify({
op: 2,
d: { subscribe_to_id: DISCORD_USER_ID },
}));
return;
}
// op 0 = Event: INIT_STATE or PRESENCE_UPDATE
if (msg.op === 0) {
const d = msg.t === "INIT_STATE"
? (msg.d && msg.d[DISCORD_USER_ID]) || msg.d
: msg.d;
latest = d;
render(d);
}
});
ws.addEventListener("open", () => { reconnectDelay = 1000; });
ws.addEventListener("close", () => {
if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
stopProgress();
// exponential backoff up to 30s
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
});
ws.addEventListener("error", () => { try { ws.close(); } catch (e) {} });
}
connect();
// keep the progress bar honest when returning to the tab
document.addEventListener("visibilitychange", () => {
if (!document.hidden && latest) render(latest);
});
})();