c.stupid.cat/js/music.js

583 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Ari was here uwu
// Professional boob lover
// girls kissing,,,
console.log(`
⣿⣿⣿⠏⣴⣿⣿⣿⣿⡿⠟⢹⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡉⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⢇⣾⣿⣿⣿⡿⢋⢀⣴⣿⣿⡿⠋⠀⠘⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣀⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⡏⣼⣿⣿⣿⠏⣴⢃⣾⣿⡿⢋⣴⠟⣠⣾⣿⣿⣿⠏⢁⣼⣿⣿⣿⣿⣿⣿⠟⣿⣿⣿⠟⠂⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠟⠛⠀⠘⠛⠛⣛⣛⣋⢉⣉⣉⣛⡛⠻⠿⣿⣿
⡟⣸⣿⣿⣿⡏⡸⢡⣾⣿⢋⣤⡿⢡⣾⣿⣿⣿⠟⠁⣰⣿⠟⣹⡿⢿⣿⠋⢀⣾⣿⣿⠏⡄⢻⡆⢀⠙⣿⣿⡿⠟⢋⣩⣤⣶⣾⣿⣿⣿⠟⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶
⢠⣿⣿⣿⣿⠃⢡⣿⡟⣡⣾⠏⣰⣿⣿⣿⡿⠋⢀⣾⡿⢁⣼⠟⢠⠞⠁⣰⣿⣿⡿⢣⣾⡇⢸⣿⣾⠆⠙⠁⣰⣾⣿⣿⣿⣿⣿⣿⣿⠏⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⡏⢠⣿⠏⣴⣿⠟⣰⣿⣿⣿⡿⣡⢃⣾⠟⢀⡞⠁⣴⢋⠄⣼⣿⣿⠏⣰⣿⡟⢀⡼⠋⣠⡶⠀⣴⣿⢿⣿⣿⢿⠏⣸⣿⡏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣧⣿⠏⣼⡿⠿⢠⣿⣿⣿⡟⣰⢃⣾⠏⢀⠞⠀⡼⢡⠟⣼⣿⡟⣡⣾⣿⠏⠀⠄⠀⣾⡿⠁⣼⡿⢃⡜⣽⡏⠈⣰⡟⠀⠁⣾⣿⣿⣿⣿⣿⣿⡇⢹⣿⣿⣿⣿⣿⡇⢻
⣿⣿⣿⣿⣿⡏⣸⣿⠃⠀⣼⣿⣿⡟⣴⢃⣾⠏⠀⢀⢀⡾⢡⡟⣰⣿⢋⣴⡿⠋⠁⣀⠀⠀⣰⡿⠁⡌⡸⢁⣾⣿⡟⠀⢠⣿⡇⠀⣸⣿⣿⣿⣿⣿⣿⣿⡇⢸⣿⣿⣿⣿⣿⡇⢸
⣿⣿⣿⣿⡿⢰⣿⠃⡆⢀⣿⣿⡟⣸⠏⣾⠏⣴⠂⠂⣾⢡⡟⣰⡟⣡⠞⣩⠄⢀⣼⠁⠀⣼⣿⠇⡼⠠⠃⣼⣿⡿⠁⢀⣾⠿⠀⠀⡏⢸⣿⣿⣿⠃⣿⣿⠇⢸⣿⣿⣿⣿⣿⡇⢸
⣿⣿⡇⢸⠇⣾⡏⣸⠃⢸⣿⡿⢱⡟⣸⡟⣼⠃⠂⣼⢃⠏⢠⠏⠰⠋⠀⢁⣠⣾⠇⠀⣼⣿⡟⠀⠁⣦⣾⣿⡿⠀⣴⡾⠀⠀⠀⠠⠁⣿⡿⢹⠉⢠⣿⣿⠀⢸⣿⣿⣿⣿⣿⡇⢸
⣿⣿⠀⣼⢰⣿⡀⡿⢀⢸⣿⢡⣿⢡⡿⠰⡏⠀⢰⠇⡞⠀⠀⣾⠞⠀⢴⣿⣿⣷⠀⣼⣿⣿⠃⠀⣸⣿⡿⡿⠀⢀⣿⠇⠀⠀⠀⠐⢀⣿⠇⡌⢠⣸⣿⡇⠀⢸⣿⣿⣿⣿⣿⠃⣸
⣿⡟⠀⡇⣸⣿⢠⡇⣏⣾⡏⣼⡇⡼⠁⠘⠁⠀⡟⡸⠀⠐⠚⢁⣦⣶⣿⣿⣿⡇⠀⣿⠇⡟⠀⠀⣿⡿⠁⠀⠀⢸⡟⠀⢰⠆⠀⡄⢸⡏⢠⠃⣿⡟⢹⡇⠀⣿⣿⣿⣿⣿⡿⠀⡏
⣿⡇⠀⡇⣿⣿⢸⡇⡟⣿⢰⣿⢡⠃⠀⠀⣰⠃⢡⠁⠈⠀⣴⣿⣿⣿⣿⣿⠟⡁⡀⢻⠀⡇⠀⢀⣿⠃⠀⠀⡄⢸⠃⠀⣿⡇⠀⠀⣼⡇⢸⢀⣿⡇⢸⠀⢀⣿⣿⣿⣿⣿⠇⢸⠁
⣿⢃⡆⡇⢿⣿⢸⣷⡇⠏⣼⡏⡌⠀⠀⠀⡏⢀⣼⡘⢀⣤⡈⠛⢿⣿⣿⣧⣾⡇⣇⠘⠀⠁⠀⢸⡏⠀⠀⣼⠃⡏⠀⢸⣿⡇⠀⠀⣿⠀⣾⣼⣿⠀⡟⠀⢸⣿⣿⣿⣿⡟⢀⡟⢠
⣿⣿⡇⡇⢸⣿⢸⠛⡇⠀⣿⠇⠁⠀⠏⣼⣷⢸⡿⢃⣾⣿⣷⣄⠀⠈⠛⢿⣿⡇⣿⡀⠀⠀⠀⠈⠀⠀⣼⣿⠀⠀⠀⣀⠙⢧⠀⠀⣿⠀⣿⣿⣿⠀⡇⠀⣼⣿⣿⣿⣿⠃⣼⠃⣾
⣿⣿⡇⠁⢸⣿⠘⠀⡇⠀⣿⠀⠀⠀⢰⣿⣿⡆⠃⣼⣿⣿⣿⣿⣷⣤⣄⣤⣽⣇⢹⡇⠀⣦⡄⠀⠀⢸⣿⡟⠀⠀⢠⣿⣷⣄⠀⠀⣿⠀⣿⣿⡇⢰⠁⠀⣿⣿⣿⣿⡟⢠⡏⢰⡿
⣿⠻⣷⠀⢸⣿⡄⠀⣷⣾⣧⠀⠀⠀⠈⣿⣿⠇⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣷⣦⡘⡷⠄⠀⣿⣿⡇⠀⣰⣾⣿⣿⣿⡇⠀⠈⠀⣿⣿⠁⠈⠀⢸⣿⣿⣿⡿⢀⣾⣧⣿⠃
⣿⡆⣿⠀⢸⣿⣇⠀⣿⣿⣿⣷⠀⢀⠀⣿⣿⣷⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠻⠷⢂⣤⣼⣿⣿⣇⢀⣿⣿⣿⣿⣿⣷⠀⠀⠀⣿⣿⠀⠀⠀⣼⣿⣿⣿⠃⣸⣿⣿⠃⠀
⣿⣧⢸⡆⠘⣿⣿⠀⢻⣿⣿⡇⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣶⣶⣿⣿⣿⣿⣿⣿⣿⠘⠛⣻⣿⣿⣿⣿⡀⠀⠀⣿⡏⢠⠀⠀⣾⣿⣿⠇⠠⢿⢻⠏⠀⠀
⢹⣿⡌⣧⠀⠻⣿⣷⣾⣿⣿⡇⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠻⣿⣿⣿⣿⣷⡆⠀⢿⠃⠀⠀⠀⣿⣿⠏⠀⠀⠆⠀⠀⠀⠀
⡌⢿⣷⢹⡆⠈⢿⣿⣿⣿⣿⠧⣿⡟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣄⡈⠛⠿⣿⣿⣷⠀⠸⠀⠀⡄⢸⣿⡏⢠⠂⠘⠀⠀⠀⠀⠀
⣷⠘⣿⡆⢿⣧⡈⠻⢿⣿⣿⠀⣿⣧⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⣡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⡆⠀⠀⢠⠁⣸⡟⢀⠎⠀⠀⠀⠀⠀⠀⢠
⣿⣧⢹⣿⡘⣿⣷⣀⠈⣿⣿⠀⣿⣿⣧⡹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⢸⠀⠟⠁⡼⠀⠀⠀⠀⠀⠀⢀⣾
⣿⣿⣆⢻⣷⡘⣿⣿⡀⠘⣿⡆⢹⣿⣿⣷⡌⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⢸⠀⠀⢰⠁⠀⠀⢀⠀⠀⢀⣿⣿
⢿⢿⣿⣦⠹⣷⠸⣿⣷⠀⠹⡇⠘⣿⣿⠿⢿⣦⣙⣿⣿⣿⣿⣿⣿⣿⣿⠟⣡⣄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠸⠀⠀⠌⠀⠀⢀⠆⠀⢀⣿⣿⣿
⠀⠈⢿⣿⣷⡙⢧⠹⣿⣇⢧⠉⠀⣿⠏⣰⣶⣤⣍⡛⠿⣿⣿⣿⣿⠟⣡⣾⣿⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠈⠀⢠⣾⣿⡿⠟
⣀⠀⠈⠻⣿⣿⣌⠣⠙⣿⡌⢧⠀⠁⣼⣿⣿⣿⣿⣿⣷⣶⣬⣭⣥⣾⣿⣿⣿⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⠀⠀⠀⠀⠀⠀⣰⣿⡟⠁⠀⠀
⣿⣿⣶⠀⠈⠛⢿⣷⡄⠈⢿⡌⣇⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⠸⣟⡀⣀⠀⢠⣼⣿⣿⣷⣿⠀⠀
⣿⣿⣿⣿⣷⣤⡀⠉⠛⢦⣀⣿⡘⡄⢹⣬⡙⣿⣿⣿⠟⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⣙⠻⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⡙⠛⠠⠶⠃⣸⣿⣿⣿⣿⠀⠀
⣿⣿⣿⣿⣿⣿⣧⡀⠀⠰⣿⣿⣷⠸⡄⠙⣷⣼⣧⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣦⣭⣭⡉⣙⡛⠛⠿⣿⣿⣿⣿⣿⡇⠐⠄⢀⢂⡀⢘⣿⣿⣿⣿⣿⣷⡄
⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀⢹⣿⣿⣧⢹⡄⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢇⣿⢿⣷⡄⠘⣿⣿⣿⣿⠇⣾⣿⣦⣤⡀⢸⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣷⣤⣈⡙⠻⢿⡇⠀⢿⣇⢻⡆⢿⡀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣧⡀⡜⢁⣤⡘⣿⣿⡿⢠⣿⣿⣿⣿⠁⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⡿⢿⣿⣿⣿⣿⡀⠘⣿⣄⢻⡘⡇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣸⣿⡀⠀⣷⡘⣿⠇⣼⣿⣿⣿⡿⢸⣿⣿⣿⣿⣿⣿⣿⣿`);
// mmmmmmmmmmmmmmmmm girls kissing,,,,,
(function music() {
"use strict";
// ---- config -------------------------------------------------------------
const DISCORD_ID = "1464890289922641993";
const LFM_USER = "Real_AlexTLM";
const LFM_KEY = "768e8bd0d366f4d6c7874740ca6610ad";
const LFM_OK = !!(LFM_USER && LFM_KEY);
// LRCLIB-compatible instances, tried in order; falls through on any failure.
const LRCLIB_HOSTS = [
"https://lrclib.schuh.wtf",
"https://lyrics.lanyard.cafe",
"https://lyrics.kie.ac",
"https://api.assumi.ng/lyrics",
"https://lyrics.aureal.dev",
"https://lrclib.net",
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---- tiny helpers -------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function mmss(ms) {
if (!isFinite(ms) || ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
}
function clamp(n, lo, hi) { return n < lo ? lo : n > hi ? hi : n; }
// ---- album art → Catppuccin accent (same maths as now-playing.js) -------
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 themePalette() {
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({ r, g, b }); }
}
return pal;
}
function nearestAccent(r, g, b) {
const pal = themePalette();
let best = null, bestD = Infinity;
for (const c of pal) {
const rm = (r + c.r) / 2, 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;
}
let lastArtUrl = null;
function applyAccent(url) {
if (!url) { resetAccent(); return; }
if (url === lastArtUrl) return;
lastArtUrl = url;
const img = new Image();
img.crossOrigin = "anonymous";
img.referrerPolicy = "no-referrer";
img.onload = () => {
try {
const c = document.createElement("canvas");
c.width = c.height = 16;
const ctx = c.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, 16, 16);
const { data } = ctx.getImageData(0, 0, 16, 16);
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 125) continue;
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);
const near = nearestAccent(r, g, b);
const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`;
document.documentElement.style.setProperty("--accent-rgb", rgb);
} catch (e) { resetAccent(); }
};
img.onerror = resetAccent;
img.src = url;
}
function resetAccent() {
lastArtUrl = null;
document.documentElement.style.removeProperty("--accent-rgb");
}
// ---- DOM refs -----------------------------------------------------------
const stage = $("#music");
if (!stage) return;
const npArt = $("#np-art");
const npState = $("#np-state");
const npTitle = $("#np-title");
const npArtist = $("#np-artist");
const npAlbum = $("#np-album");
const npLink = $("#np-link");
const barFill = $("#np-fill");
const barCur = $("#np-cur");
const barDur = $("#np-dur");
const progress = $("#np-progress");
const lyricsBox = $("#lyrics");
const lockBtn = $("#ly-lock");
const recentBox = $("#recent");
const topBox = $("#top");
// transparent 1x1 — keeps <img> valid (src required) while showing the ♪
// placeholder via CSS (.mnp-art:not(.has-art)) when there's no real art
const BLANK_ART = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
// ---- state --------------------------------------------------------------
// track: { song, artist, album, art, trackId, url, start, end, duration, live }
let track = null; // what's on the hero right now
let lyrics = null; // { synced:[{t,text}], plain, instrumental, key }
let lyricsReq = 0; // race guard for async lyric fetches
let activeLine = -1;
const lyricsCache = new Map(); // trackKey → normalized lyrics (instant on repeat)
let locked = true; // follow the current line; released when the user scrolls
function trackKey(t) {
return t ? [t.song, t.artist, t.album].map((x) => (x || "").toLowerCase()).join("\u241F") : "";
}
// =======================================================================
// NOW PLAYING (hero)
// =======================================================================
function paintHero() {
if (!track) {
stage.classList.add("is-idle");
npState.textContent = "Not listening right now";
npTitle.textContent = "—";
npArtist.textContent = "";
npAlbum.textContent = "";
npArt.src = BLANK_ART;
npArt.classList.remove("has-art");
npLink.removeAttribute("href");
npLink.removeAttribute("target");
npLink.removeAttribute("rel");
progress.hidden = true;
resetAccent();
return;
}
stage.classList.toggle("is-idle", false);
stage.classList.toggle("is-live", !!track.live);
npState.textContent = track.live ? "Listening now" : "Last played";
npTitle.textContent = track.song || "Unknown track";
npArtist.textContent = track.artist || "";
npAlbum.textContent = track.album || "";
if (track.art) { npArt.src = track.art; npArt.classList.add("has-art"); }
else { npArt.src = BLANK_ART; npArt.classList.remove("has-art"); }
if (track.url) { npLink.href = track.url; npLink.target = "_blank"; npLink.rel = "noopener"; }
else { npLink.removeAttribute("href"); npLink.removeAttribute("target"); npLink.removeAttribute("rel"); }
// progress bar only makes sense for a live track with real timestamps
progress.hidden = !(track.live && track.start && track.end);
if (!progress.hidden) barDur.textContent = mmss(track.duration);
applyAccent(track.art);
}
function setTrack(next) {
const changed = trackKey(next) !== trackKey(track);
track = next;
paintHero();
if (changed) {
activeLine = -1;
loadLyrics(next);
}
}
// =======================================================================
// LYRICS (LRCLIB)
// =======================================================================
function parseLRC(text) {
if (!text) return [];
const out = [];
const tag = /\[(\d{1,2}):(\d{1,2}(?:[.:]\d{1,3})?)\]/g;
text.split(/\r?\n/).forEach((line) => {
tag.lastIndex = 0;
const stamps = [];
let m, last = 0;
while ((m = tag.exec(line))) {
const mins = parseInt(m[1], 10);
const secs = parseFloat(m[2].replace(":", "."));
stamps.push((mins * 60 + secs) * 1000);
last = tag.lastIndex;
}
if (!stamps.length) return;
const words = line.slice(last).trim();
stamps.forEach((t) => out.push({ t, text: words }));
});
out.sort((a, b) => a.t - b.t);
return out;
}
function lyricsLoading() {
lyricsBox.className = "lyrics is-loading";
lyricsBox.innerHTML = '<p class="ly-note">Finding lyrics…</p>';
}
function lyricsEmpty(msg) {
lyricsBox.className = "lyrics is-empty";
lyricsBox.innerHTML = '<p class="ly-note">' + esc(msg) + "</p>";
}
function renderLyrics(data) {
lyrics = data;
activeLine = -1;
locked = true; // every new track starts in follow mode
const synced = data && data.synced && data.synced.length;
updateLock(synced);
if (!data) { lyricsEmpty("No track playing."); return; }
if (data.instrumental) {
lyricsBox.className = "lyrics is-instrumental";
lyricsBox.innerHTML = '<p class="ly-note">♪ instrumental ♪</p>';
return;
}
if (synced) {
lyricsBox.className = "lyrics is-synced";
lyricsBox.innerHTML = data.synced
.map((l, i) => '<p class="ly-line" data-i="' + i + '">' + (esc(l.text) || "&nbsp;") + "</p>")
.join("");
lyricsBox.scrollTop = 0;
return;
}
if (data.plain) {
lyricsBox.className = "lyrics is-plain";
lyricsBox.innerHTML = data.plain.split(/\r?\n/)
.map((l) => '<p class="ly-line ly-static">' + (esc(l) || "&nbsp;") + "</p>").join("");
return;
}
lyricsEmpty("No lyrics found for this one.");
}
// ---- lock / follow controls --------------------------------------------
let selfScroll = false; // true while WE are scrolling, so we don't self-release
function centerLine(i, smooth) {
const el = lyricsBox.children[i];
if (!el) return;
const top = el.offsetTop - lyricsBox.clientHeight / 2 + el.clientHeight / 2;
selfScroll = true;
lyricsBox.scrollTo({ top, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
setTimeout(() => { selfScroll = false; }, smooth && !reduceMotion ? 600 : 50);
}
function updateLock(show) {
if (!lockBtn) return;
lockBtn.hidden = !show;
lockBtn.classList.toggle("is-locked", locked);
lockBtn.setAttribute("aria-pressed", String(locked));
let label = lockBtn.querySelector(".ly-lock-label");
if (!label) {
lockBtn.innerHTML =
'<span class="ly-bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>' +
'<span class="ly-lock-label"></span>';
label = lockBtn.querySelector(".ly-lock-label");
}
label.textContent = locked ? "Synced" : "Sync";
}
function release() { // user took over the scroll
if (!locked) return;
locked = false;
if (lockBtn) updateLock(!lockBtn.hidden);
}
function reLock() { // jump back to the current line and follow again
locked = true;
updateLock(true);
if (activeLine >= 0) centerLine(activeLine, true);
}
if (lockBtn) {
lockBtn.addEventListener("click", () => (locked ? release() : reLock()));
}
// user-driven scroll intent releases the lock (programmatic scrolls don't)
["wheel", "touchmove"].forEach((ev) =>
lyricsBox.addEventListener(ev, () => { if (!selfScroll) release(); }, { passive: true }));
lyricsBox.addEventListener("keydown", (e) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key)) release();
});
// LRCLIB lookups, narrowest match first. Walks the instance list until one
// returns a hit; a 404/non-OK just means "this mirror doesn't have it" → next.
async function lrclibGet(params) {
const qs = new URLSearchParams(params).toString();
for (const host of LRCLIB_HOSTS) {
try {
const res = await fetch(host + "/api/get?" + qs, {
headers: { "X-User-Agent": "clove.is-a.dev music (https://clove.is-a.dev)" },
});
if (res.ok) return res.json();
} catch (e) { /* network/CORS error → try the next instance */ }
}
return null;
}
async function lrclibSearch(track_name, artist_name) {
const qs = new URLSearchParams({ track_name, artist_name }).toString();
for (const host of LRCLIB_HOSTS) {
try {
const res = await fetch(host + "/api/search?" + qs);
if (!res.ok) continue;
const arr = await res.json();
if (!Array.isArray(arr) || !arr.length) continue;
// prefer a result that actually has synced lyrics
return arr.find((r) => r.syncedLyrics) || arr.find((r) => r.plainLyrics) || arr[0];
} catch (e) { /* next instance */ }
}
return null;
}
function normalize(rec) {
if (!rec) return null;
return {
instrumental: !!rec.instrumental,
synced: parseLRC(rec.syncedLyrics),
plain: rec.plainLyrics || "",
};
}
async function loadLyrics(t) {
const myReq = ++lyricsReq;
if (!t) { renderLyrics(null); return; }
const key = trackKey(t);
if (lyricsCache.has(key)) { renderLyrics(lyricsCache.get(key)); return; } // instant
lyricsLoading();
let rec = null;
try {
if (t.live && t.duration) {
// exact-ish match using duration (±2s tolerance handled by LRCLIB)
rec = await lrclibGet({
track_name: t.song, artist_name: t.artist,
album_name: t.album || "", duration: Math.round(t.duration / 1000),
});
}
if (!rec) { // drop album / no-duration → fall back to search
rec = await lrclibSearch(t.song || "", t.artist || "");
}
} catch (e) { rec = null; }
if (myReq !== lyricsReq) return; // a newer track superseded us
const data = normalize(rec);
lyricsCache.set(key, data);
renderLyrics(data);
}
// =======================================================================
// TICKER — sync the progress bar + active lyric line to playback
// =======================================================================
function tick() {
if (track && track.live && track.start && track.end) {
const pos = clamp(Date.now() - track.start, 0, track.duration);
// progress bar
if (!progress.hidden) {
barFill.style.width = (pos / track.duration) * 100 + "%";
barCur.textContent = mmss(pos);
}
// active synced line
if (lyrics && lyrics.synced && lyrics.synced.length) {
let i = -1;
for (let k = 0; k < lyrics.synced.length; k++) {
if (lyrics.synced[k].t <= pos) i = k; else break;
}
if (i !== activeLine) {
const lines = lyricsBox.children;
if (activeLine >= 0 && lines[activeLine]) lines[activeLine].classList.remove("is-active");
activeLine = i;
if (lines[i]) {
lines[i].classList.add("is-active");
if (locked) centerLine(i, true);
}
}
}
}
requestAnimationFrame(tick);
}
// =======================================================================
// LANYARD — live Discord presence (same socket as the card)
// =======================================================================
let ws = null, heartbeat = null, retry = 0;
function fromSpotify(s) {
return {
song: s.song, artist: s.artist, album: s.album,
art: s.album_art_url || "",
trackId: s.track_id || "",
url: s.track_id ? "https://open.spotify.com/track/" + s.track_id : "",
start: s.timestamps && s.timestamps.start,
end: s.timestamps && s.timestamps.end,
duration: s.timestamps ? s.timestamps.end - s.timestamps.start : 0,
live: true,
};
}
function onPresence(d) {
if (d && d.listening_to_spotify && d.spotify) {
setTrack(fromSpotify(d.spotify));
} else if (track && track.live) {
// they just stopped — fall back to the latest scrobble for the hero
track = null;
showIdle();
} else if (!track) {
showIdle();
}
}
function connectLanyard() {
try { ws = new WebSocket("wss://api.lanyard.rest/socket"); }
catch (e) { return; }
ws.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; }
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_ID } }));
} else if (msg.op === 0 && (msg.t === "INIT_STATE" || msg.t === "PRESENCE_UPDATE")) {
onPresence(msg.d);
}
};
ws.onclose = () => {
if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
retry = Math.min(retry + 1, 6);
setTimeout(connectLanyard, 1000 * retry);
};
ws.onerror = () => { if (ws) ws.close(); };
}
// =======================================================================
// LAST.FM — recently played, top artists, and the idle fallback headline
// =======================================================================
const LFM = "https://ws.audioscrobbler.com/2.0/";
const LFM_PLACEHOLDER = "2a96cbd8b46e442fc41c2b86b821562f"; // last.fm "no art" star
function lfmImg(images) {
if (!Array.isArray(images)) return "";
const big = images[images.length - 1] || images[0] || {};
const url = big["#text"] || "";
return url && url.indexOf(LFM_PLACEHOLDER) === -1 ? url : "";
}
function timeAgo(uts) {
const diff = Math.floor(Date.now() / 1000) - Number(uts);
if (!isFinite(diff) || diff < 0) return "";
if (diff < 60) return "just now";
if (diff < 3600) return Math.floor(diff / 60) + " min ago";
if (diff < 86400) return Math.floor(diff / 3600) + " hr ago";
return Math.floor(diff / 86400) + " day" + (diff < 172800 ? "" : "s") + " ago";
}
async function lfm(method, extra) {
const qs = new URLSearchParams(Object.assign(
{ method, user: LFM_USER, api_key: LFM_KEY, format: "json" }, extra || {}
)).toString();
const res = await fetch(LFM + "?" + qs);
if (!res.ok) throw new Error("last.fm " + res.status);
return res.json();
}
async function showIdle() {
// pull the latest scrobble so the page isn't a dead end when nobody's listening
if (!LFM_OK) { paintHero(); return; }
try {
const data = await lfm("user.getrecenttracks", { limit: 1 });
if (track && track.live) return; // a live presence arrived while we waited
const t = data && data.recenttracks && data.recenttracks.track;
const last = Array.isArray(t) ? t[0] : t;
if (last) {
setTrack({
song: last.name,
artist: last.artist && (last.artist["#text"] || last.artist.name),
album: last.album && last.album["#text"],
art: lfmImg(last.image),
url: last.url || "",
live: false,
});
return;
}
} catch (e) { /* fall through */ }
paintHero();
}
async function loadRecent() {
if (!LFM_OK) {
recentBox.innerHTML =
'<li class="rc-note">Add your Last.fm username + key to the ' +
'<code>music.js</code> script tag to show recent plays.</li>';
return;
}
try {
const data = await lfm("user.getrecenttracks", { limit: 12 });
const arr = (data && data.recenttracks && data.recenttracks.track) || [];
const list = Array.isArray(arr) ? arr : [arr];
if (!list.length) { recentBox.innerHTML = '<li class="rc-note">No recent scrobbles.</li>'; return; }
recentBox.innerHTML = list.map((t) => {
const now = t["@attr"] && t["@attr"].nowplaying === "true";
const art = lfmImg(t.image);
const when = now
? '<span class="rc-now">scrobbling now</span>'
: '<span class="rc-when">' + esc(timeAgo(t.date && t.date.uts)) + "</span>";
const artist = t.artist && (t.artist["#text"] || t.artist.name) || "";
return '<li class="rc-item' + (now ? " is-now" : "") + '">' +
'<a href="' + esc(t.url || "#") + '" target="_blank" rel="noopener">' +
(art ? '<img class="rc-art" src="' + esc(art) + '" alt="" loading="lazy">'
: '<span class="rc-art rc-art-blank" aria-hidden="true">♪</span>') +
'<span class="rc-text">' +
'<span class="rc-name">' + esc(t.name) + "</span>" +
'<span class="rc-artist">' + esc(artist) + "</span>" +
"</span>" + when +
"</a></li>";
}).join("");
} catch (e) {
recentBox.innerHTML = '<li class="rc-note">Couldnt reach Last.fm just now.</li>';
}
}
async function loadTop() {
if (!LFM_OK || !topBox) { if (topBox) topBox.hidden = true; return; }
try {
const data = await lfm("user.gettopartists", { period: "7day", limit: 8 });
const arr = (data && data.topartists && data.topartists.artist) || [];
if (!arr.length) { topBox.hidden = true; return; }
topBox.hidden = false;
topBox.innerHTML = '<h2 class="sec-title">Top artists · last 7 days</h2>' +
'<ol class="top-chips">' + arr.map((a, i) =>
'<li class="top-chip"><a href="' + esc(a.url) + '" target="_blank" rel="noopener">' +
'<span class="top-rank">' + (i + 1) + "</span>" +
'<span class="top-name">' + esc(a.name) + "</span>" +
'<span class="top-plays">' + esc(a.playcount) + " plays</span>" +
"</a></li>").join("") + "</ol>";
} catch (e) { topBox.hidden = true; }
}
// =======================================================================
// boot
// =======================================================================
paintHero();
showIdle(); // headline + lyrics before the socket warms up
connectLanyard(); // takes over the hero the moment a presence arrives
loadRecent();
loadTop();
requestAnimationFrame(tick);
if (LFM_OK) setInterval(loadRecent, 45000);
})();