diff --git a/404.html b/404.html deleted file mode 100644 index b1d2dac..0000000 --- a/404.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - clove - - - - - - - - - - - - - -
- ← API docs -
-

- No presence yet. If this stays empty, the user probably hasn't joined - discord.gg/lanyard - yet — that's what lets the card track them. -

-
- - -
-

404

-

That page wandered off.

-
- ← home - Now-Playing API -
-
- - - - - - diff --git a/api/index.html b/api/index.html deleted file mode 100644 index 38e429d..0000000 --- a/api/index.html +++ /dev/null @@ -1,306 +0,0 @@ - - - - - - - Now-Playing API · clove - - - - - - - - - - - - - - - - - - - - -
- ← API docs -
-

- No presence yet. If this stays empty, the user probably hasn't joined - discord.gg/lanyard - yet — that's what lets the card track them. -

-
- - -
- live · free for friends -

Now-Playing API

-

- The same Discord presence card from my homepage — but pointed at your - Discord. Give it your user ID and you get a live card with your status, Spotify, - games, what you're coding, badges, the lot. No accounts, no keys, no Google Sheet. -

- -
- -
- - -
-
- - - - -
- -
- -

1. Join Lanyard

-

- The card reads your presence through Lanyard, - which only tracks people in its Discord server. So join you will need to join it and stay there for - it to work. Join at: - discord.gg/lanyard. -

- -

2. Grab your user ID

-

- In Discord: Settings → Advanced → Developer Mode (turn it on), - then right-click your name and Copy User ID. It's a long number - like 1464890289922641993. -

- -

3. Use your card

-

Three ways to point it at your ID — all do the same thing:

-
https://clove.is-a.dev/api/YOUR_ID      ← prettiest
-https://clove.is-a.dev/api/?u=YOUR_ID
-https://clove.is-a.dev/api/#YOUR_ID
-

Pick a theme by adding ?theme=mocha (default), - macchiato, frappe, or latte:

-
https://clove.is-a.dev/api/YOUR_ID?theme=latte
- -

Embed it anywhere

-

Drop it into Notion, a website, an OBS browser source, a README iframe — anywhere that takes HTML or a URL:

-
<iframe
-  src="https://clove.is-a.dev/api/YOUR_ID"
-  style="border:0;width:340px;height:360px"
-  title="my Discord presence"></iframe>
-

- Want it on your own page without the iframe? Add a mount + the two files and - pass your ID via data-user: -

-
<link rel="stylesheet" href="https://clove.is-a.dev/api/api.css">
-<div id="now-playing"></div>
-<script src="https://clove.is-a.dev/api/now-playing.js"
-        data-user="YOUR_ID"></script>
- -

What it shows

-

- Avatar, decoration & status dot · display name (incl. Discord's gradient name styles) · - server tag · active platforms (desktop/mobile/web) · Discord badges (Nitro, boosts, etc. - via dstn) · custom status · live Spotify with a progress bar · what game you're playing · - what you're coding (VS Code) · streaming. The card auto-tints to your Spotify album art. -

- -

Notes & limits

- - - ← back to clove.is-a.dev -
- - - - - - diff --git a/cool-people/index.html b/cool-people/index.html index 35254eb..4019bac 100644 --- a/cool-people/index.html +++ b/cool-people/index.html @@ -6,11 +6,6 @@ Cool People - - - - - @@ -53,6 +48,7 @@ Cool People Dev Info Discord Bots + Music @@ -171,7 +167,7 @@ - + diff --git a/css/main.css b/css/main.css index 82e2449..0bbd703 100644 --- a/css/main.css +++ b/css/main.css @@ -23,7 +23,7 @@ @import url(/css/themes/latte.css); @import url(/css/themes/mocha.css); @import url(/css/themes/macchiato.css); -@import url(/api/api.css); +@import url(/css/player.css); /* ============================================================ 1. BASE & RESET @@ -2180,4 +2180,254 @@ body:has(.friend-grid) .hub-header { .friend:hover .friend-pfp { filter: none; +} + + +/* ===================================================================== + * MUSIC PAGE (/music) — merged in from music.css. + * Hero classes are .mnp-* to avoid colliding with the .np-* now-playing + * widget already defined above. Other classes (.lyrics, .rc-*, .sec-*, + * .top-*, .ly-*, .music-*) are unique to this page. + * ===================================================================== */ + +/* Let the music page scroll; the link hub stays locked (same pattern as + .dev-info / .changelog above). Without this, html,body{overflow:hidden} + from the homepage layout traps the page. */ +html:has(.music-wrap), +body:has(.music-wrap) { + height: auto; + min-height: 100dvh; + overflow-y: auto; +} +body:has(.music-wrap) { + align-items: flex-start; +} + +.music-wrap { + max-width: 880px; + margin: 0 auto; + padding: 2.6rem 1.25rem 5rem; + font-family: 'Comic Code', ui-monospace, monospace; +} + +.music-head { margin: 0 0 1.6rem; } +.music-head h1 { + font-size: clamp(1.7rem, 5vw, 2.4rem); + margin: 0 0 0.2rem; + color: rgb(var(--accent-rgb)); + letter-spacing: -0.02em; + transition: color 0.5s ease; +} +.music-head p { margin: 0; color: var(--subtext-0); font-size: 0.95rem; } + +/* ---- now playing hero -------------------------------------------------- */ +.mnp { + display: grid; + grid-template-columns: 132px 1fr; + gap: 1.1rem; + align-items: center; + background: var(--mantle); + border: 1px solid var(--surface-0); + border-radius: 18px; + padding: 1.1rem; + position: relative; + overflow: hidden; +} +/* a soft wash of the album accent behind the hero */ +.mnp::before { + content: ""; + position: absolute; inset: 0; + background: radial-gradient(120% 140% at 0% 0%, + rgba(var(--accent-rgb), 0.18), transparent 60%); + opacity: 0; transition: opacity 0.6s ease; + pointer-events: none; +} +#music.is-live .mnp::before { opacity: 1; } + +.mnp-art { + width: 132px; height: 132px; + border-radius: 12px; + object-fit: cover; + background: var(--surface-0); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.mnp-art:not(.has-art) { display: grid; } +.mnp-art:not(.has-art)::after { + content: "♪"; color: var(--overlay-0); font-size: 2.4rem; + display: grid; place-items: center; height: 100%; +} +.mnp-meta { min-width: 0; position: relative; } +.mnp-state { + display: inline-flex; align-items: center; gap: 0.4rem; + font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--subtext-0); margin-bottom: 0.35rem; +} +#music.is-live .mnp-state { color: rgb(var(--accent-rgb)); } +#music.is-live .mnp-state::before { + content: ""; width: 7px; height: 7px; border-radius: 50%; + background: rgb(var(--accent-rgb)); + box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.6); + animation: np-pulse 2s infinite; +} +@keyframes np-pulse { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.5); } + 70% { box-shadow: 0 0 0 7px rgba(var(--accent-rgb), 0); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } +} +.mnp-title { + display: block; font-size: 1.3rem; font-weight: 700; color: var(--text); + margin: 0 0 0.15rem; line-height: 1.2; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.mnp-title:hover { color: rgb(var(--accent-rgb)); } +.mnp-artist { display: block; color: var(--subtext-1); font-size: 0.95rem; } +.mnp-album { display: block; color: var(--subtext-0); font-size: 0.82rem; margin-top: 0.1rem; } + +.mnp-progress { margin-top: 0.8rem; display: flex; align-items: center; gap: 0.6rem; } +.mnp-bar { + flex: 1; height: 6px; border-radius: 999px; + background: var(--surface-0); overflow: hidden; +} +.mnp-fill { + height: 100%; width: 0%; + background: rgb(var(--accent-rgb)); + border-radius: 999px; transition: width 0.4s linear; +} +.mnp-time { font-size: 0.72rem; color: var(--subtext-0); font-variant-numeric: tabular-nums; } + +/* ---- lyrics (the centrepiece) ------------------------------------------ */ +.sec-title { + font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.07em; + color: var(--subtext-0); margin: 2.4rem 0 0.7rem; font-weight: 500; +} +.sec-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 0.8rem; margin: 2.4rem 0 0.7rem; +} +.sec-row .sec-title { margin: 0; } +.ly-lock { + font-family: inherit; font-size: 0.72rem; letter-spacing: 0.04em; + cursor: pointer; border-radius: 999px; + padding: 0.28rem 0.8rem 0.28rem 0.7rem; + background: var(--surface-0); color: var(--subtext-1); + border: 1px solid transparent; + display: inline-flex; align-items: center; gap: 0.4rem; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} +.ly-lock::before { + content: ""; width: 7px; height: 7px; border-radius: 50%; + background: currentColor; opacity: 0.6; +} +.ly-lock.is-locked { + color: rgb(var(--accent-rgb)); + border-color: rgba(var(--accent-rgb), 0.4); + background: rgba(var(--accent-rgb), 0.1); +} +.ly-lock.is-locked::before { opacity: 1; } +.ly-lock:not(.is-locked):hover { color: var(--text); border-color: var(--surface-1); } + +.lyrics { + position: relative; /* anchor offsetTop for the follow scroll */ + height: 340px; + overflow-y: auto; + scroll-behavior: smooth; + overscroll-behavior: contain; + border-radius: 16px; + background: var(--crust); + border: 1px solid var(--surface-0); + padding: 1.4rem 1.4rem; + scrollbar-width: thin; + scrollbar-color: var(--surface-1) transparent; + /* fade the top + bottom so lines drift in and out */ + -webkit-mask-image: linear-gradient(180deg, transparent, #000 14%, #000 86%, transparent); + mask-image: linear-gradient(180deg, transparent, #000 14%, #000 86%, transparent); +} +.lyrics::-webkit-scrollbar { width: 8px; } +.lyrics::-webkit-scrollbar-thumb { background: var(--surface-1); border-radius: 999px; } + +.ly-line { + margin: 0; padding: 0.32rem 0; + font-size: 1.18rem; line-height: 1.4; + color: var(--overlay-0); + transition: color 0.3s ease, opacity 0.3s ease, transform 0.3s ease; +} +.is-synced .ly-line { opacity: 0.55; } +.is-synced .ly-line.is-active { + color: rgb(var(--accent-rgb)); + opacity: 1; + font-weight: 700; + transform: translateX(2px); +} +.ly-static { color: var(--subtext-1); opacity: 1; font-size: 1.05rem; } +.ly-note { + color: var(--subtext-0); font-size: 0.95rem; text-align: center; + margin: 0; padding-top: 1rem; +} +.lyrics.is-instrumental, .lyrics.is-empty, .lyrics.is-loading { + display: grid; place-content: center; height: 180px; + -webkit-mask-image: none; mask-image: none; +} +.lyrics.is-instrumental .ly-note { color: rgb(var(--accent-rgb)); font-size: 1.2rem; } + +/* ---- recently played --------------------------------------------------- */ +.recent { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; } +.rc-item a { + display: grid; grid-template-columns: 44px 1fr auto; gap: 0.7rem; + align-items: center; text-decoration: none; + padding: 0.45rem 0.55rem; border-radius: 12px; + transition: background 0.15s ease; +} +.rc-item a:hover { background: var(--surface-0); } +.rc-art { + width: 44px; height: 44px; border-radius: 8px; + object-fit: cover; background: var(--surface-0); +} +.rc-art-blank { display: grid; place-items: center; color: var(--overlay-0); font-size: 1.1rem; } +.rc-text { min-width: 0; } +.rc-name { + display: block; color: var(--text); font-size: 0.92rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.rc-artist { + display: block; color: var(--subtext-0); font-size: 0.78rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.rc-when, .rc-now { font-size: 0.72rem; white-space: nowrap; } +.rc-when { color: var(--subtext-0); } +.rc-now { color: rgb(var(--accent-rgb)); font-weight: 700; } +.is-now { background: rgba(var(--accent-rgb), 0.08); border-radius: 12px; } +.rc-note { color: var(--subtext-0); font-size: 0.86rem; padding: 0.6rem 0.4rem; line-height: 1.5; } +.rc-note code { + background: var(--surface-0); color: var(--text); + padding: 0.1rem 0.35rem; border-radius: 6px; font-size: 0.9em; +} + +/* ---- top artists ------------------------------------------------------- */ +.top-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; } +.top-chip a { + display: inline-flex; align-items: baseline; gap: 0.45rem; + background: var(--mantle); border: 1px solid var(--surface-0); + border-radius: 999px; padding: 0.35rem 0.8rem; text-decoration: none; + transition: border-color 0.15s ease; +} +.top-chip a:hover { border-color: rgb(var(--accent-rgb)); } +.top-rank { color: rgb(var(--accent-rgb)); font-weight: 700; font-size: 0.78rem; } +.top-name { color: var(--text); font-size: 0.85rem; } +.top-plays { color: var(--subtext-0); font-size: 0.72rem; } + +.music-back { display: inline-block; margin-top: 2.6rem; font-size: 0.85rem; color: rgb(var(--accent-rgb)); } + +/* ---- responsive -------------------------------------------------------- */ +@media (max-width: 560px) { + .mnp { grid-template-columns: 96px 1fr; gap: 0.85rem; padding: 0.9rem; } + .mnp-art { width: 96px; height: 96px; } + .mnp-title { font-size: 1.1rem; } + .lyrics { height: 300px; } + .ly-line { font-size: 1.05rem; } +} + +@media (prefers-reduced-motion: reduce) { + .mnp-fill { transition: none; } + #music.is-live .mnp-state::before { animation: none; } + .ly-line { transition: color 0.15s ease; } } \ No newline at end of file diff --git a/api/api.css b/css/player.css similarity index 91% rename from api/api.css rename to css/player.css index b89a008..f4b7fdf 100644 --- a/api/api.css +++ b/css/player.css @@ -1,24 +1,3 @@ -/* ===================================================================== - * api.css — standalone styles for the Now-Playing presence API. - * - * Two jobs: - * 1. Catppuccin theme variables (4 flavors, picked via ?theme= → the - * attribute). On the main site the card is - * themed by the site's own data-flavor vars instead, so these - * data-theme blocks never collide with the homepage. - * 2. The presence-card component itself (.presence-card / .pc-*), lifted - * out of main.css so the card is fully self-contained here. - * - * Anyone can drop /api/now-playing.js + this file onto a page with a - *
and get a live Discord presence card. - * ===================================================================== */ - - -/* ===================================================================== - * 1. THEMES — Catppuccin (https://github.com/catppuccin/catppuccin) - * Only applied when is set (the API pages do - * this from the ?theme= param, defaulting to mocha). - * ===================================================================== */ html[data-theme="mocha"] { color-scheme: dark; --rosewater: #f5e0dc; --flamingo: #f2cdcd; --pink: #f5c2e7; diff --git a/dev-info/index.html b/dev-info/index.html index 90d7ea8..51a86e6 100644 --- a/dev-info/index.html +++ b/dev-info/index.html @@ -50,6 +50,7 @@ Cool People Dev Info Discord Bots + Music @@ -226,7 +227,7 @@ - + diff --git a/discord-bots/index.html b/discord-bots/index.html index 6a01854..eb554eb 100644 --- a/discord-bots/index.html +++ b/discord-bots/index.html @@ -6,11 +6,6 @@ Clove Twilight - Discord Bots - - - - - @@ -55,6 +50,7 @@ Cool People Dev Info Discord Bots + Music @@ -218,7 +214,7 @@ - + diff --git a/index.html b/index.html index 7304a15..62b60d6 100644 --- a/index.html +++ b/index.html @@ -6,11 +6,6 @@ Clove Twilight - Link Center - - - - - @@ -52,6 +47,7 @@ Cool People Dev Info Discord Bots + Music @@ -101,7 +97,7 @@ - + diff --git a/js/music.js b/js/music.js new file mode 100644 index 0000000..a3d9e00 --- /dev/null +++ b/js/music.js @@ -0,0 +1,531 @@ +(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, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); + } + 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"); + + // ---- 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.removeAttribute("src"); + npArt.classList.remove("has-art"); + npLink.removeAttribute("href"); + 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.removeAttribute("src"); npArt.classList.remove("has-art"); } + if (track.url) npLink.href = track.url; else npLink.removeAttribute("href"); + // 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 = '

Finding lyrics…

'; + } + function lyricsEmpty(msg) { + lyricsBox.className = "lyrics is-empty"; + lyricsBox.innerHTML = '

' + esc(msg) + "

"; + } + 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 = '

♪ instrumental ♪

'; + return; + } + if (synced) { + lyricsBox.className = "lyrics is-synced"; + lyricsBox.innerHTML = data.synced + .map((l, i) => '

' + (esc(l.text) || " ") + "

") + .join(""); + lyricsBox.scrollTop = 0; + return; + } + if (data.plain) { + lyricsBox.className = "lyrics is-plain"; + lyricsBox.innerHTML = data.plain.split(/\r?\n/) + .map((l) => '

' + (esc(l) || " ") + "

").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)); + lockBtn.textContent = locked ? "Following" : "Resume"; + } + 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 = + '
  • Add your Last.fm username + key to the ' + + 'music.js script tag to show recent plays.
  • '; + 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 = '
  • No recent scrobbles.
  • '; return; } + recentBox.innerHTML = list.map((t) => { + const now = t["@attr"] && t["@attr"].nowplaying === "true"; + const art = lfmImg(t.image); + const when = now + ? 'scrobbling now' + : '' + esc(timeAgo(t.date && t.date.uts)) + ""; + const artist = t.artist && (t.artist["#text"] || t.artist.name) || ""; + return '
  • ' + + '' + + (art ? '' + : '') + + '' + + '' + esc(t.name) + "" + + '' + esc(artist) + "" + + "" + when + + "
  • "; + }).join(""); + } catch (e) { + recentBox.innerHTML = '
  • Couldn’t reach Last.fm just now.
  • '; + } + } + + 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 = '

    Top artists · last 7 days

    ' + + '
      ' + arr.map((a, i) => + '
    1. ' + + '' + (i + 1) + "" + + '' + esc(a.name) + "" + + '' + esc(a.playcount) + " plays" + + "
    2. ").join("") + "
    "; + } 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); +})(); \ No newline at end of file diff --git a/api/now-playing.js b/js/now-playing.js similarity index 95% rename from api/now-playing.js rename to js/now-playing.js index ec74946..db0344b 100644 --- a/api/now-playing.js +++ b/js/now-playing.js @@ -1,25 +1,3 @@ -/* ===================================================================== - * now-playing.js — a single Discord-style presence card. (API edition) - * - * Base state is a compact profile pill (avatar + name + status dot). - * It auto-expands a row for whatever is going on, in this order: - * custom status · Spotify · development · games · streaming - * Data comes live from Lanyard over a websocket. Album art drives the - * card's accent colour. - * - * WHICH USER? The Discord user id is resolved, in priority order, from: - * 1. the path /api/ - * 2. the query ?u= (also ?id= / ?user=) - * 3. the hash # - * 4. + + + + + + + + + + + + + + + + + + + + + + +
    +
    +

    Music

    +

    What I'm listening to, live — with lyrics that follow along.

    +
    + + + + +
    + Connecting… + + + + +
    +
    + + +
    +

    Lyrics

    + +
    +

    Waiting for a track…

    + + +

    Recently played

    +
      + + + + + ← back to clove.is-a.dev +
      + + + + + + + + + \ No newline at end of file