Add music player
This commit is contained in:
parent
4020eef54c
commit
47782e28e6
92
404.html
92
404.html
|
|
@ -1,92 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>clove</title>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
|
||||||
<link rel="stylesheet" href="/css/fonts.css">
|
|
||||||
<link rel="stylesheet" href="/api/api.css">
|
|
||||||
|
|
||||||
<!-- GitHub Pages serves this for any unknown path. We use it to make the
|
|
||||||
pretty /api/<id> URLs render the Now-Playing card. Anything else is a
|
|
||||||
genuine 404. -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var m = location.pathname.match(/^\/api\/(\d{5,25})\/?$/);
|
|
||||||
var p = new URLSearchParams(location.search);
|
|
||||||
var t = p.get('theme');
|
|
||||||
var ok = ['mocha', 'macchiato', 'frappe', 'latte'];
|
|
||||||
document.documentElement.setAttribute('data-theme', ok.indexOf(t) >= 0 ? t : 'mocha');
|
|
||||||
document.documentElement.setAttribute('data-mode', m ? 'card' : '404');
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
html[data-mode="card"] .nf { display: none; }
|
|
||||||
html[data-mode="404"] .api-stage { display: none; }
|
|
||||||
|
|
||||||
.nf {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
.nf h1 { font-size: 4rem; margin: 0; color: rgb(var(--accent-rgb)); }
|
|
||||||
.nf p { color: var(--subtext-1); margin: 0.2rem 0; }
|
|
||||||
.nf a { color: rgb(var(--accent-rgb)); }
|
|
||||||
.nf .row { display: flex; gap: 1rem; margin-top: 1rem; flex-wrap: wrap; justify-content: center; }
|
|
||||||
|
|
||||||
.api-stage .back {
|
|
||||||
position: fixed; top: 1rem; left: 1rem;
|
|
||||||
font-size: 0.8rem; color: var(--subtext-0); text-decoration: none;
|
|
||||||
background: var(--surface-0); padding: 0.3rem 0.7rem; border-radius: 999px;
|
|
||||||
}
|
|
||||||
.api-empty { display: none; }
|
|
||||||
html[data-mode="card"] .api-empty.show { display: block; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="api-body">
|
|
||||||
|
|
||||||
<!-- /api/<id> → render the card -->
|
|
||||||
<div class="api-stage">
|
|
||||||
<a class="back" href="/api">← API docs</a>
|
|
||||||
<div id="now-playing"></div>
|
|
||||||
<p class="api-empty">
|
|
||||||
No presence yet. If this stays empty, the user probably hasn't joined
|
|
||||||
<a href="https://discord.gg/lanyard" target="_blank" rel="noopener">discord.gg/lanyard</a>
|
|
||||||
yet — that's what lets the card track them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- everything else → a real 404 -->
|
|
||||||
<div class="nf">
|
|
||||||
<h1>404</h1>
|
|
||||||
<p>That page wandered off.</p>
|
|
||||||
<div class="row">
|
|
||||||
<a href="/">← home</a>
|
|
||||||
<a href="/api">Now-Playing API</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/api/now-playing.js"></script>
|
|
||||||
<script>
|
|
||||||
if (document.documentElement.getAttribute('data-mode') === 'card') {
|
|
||||||
setTimeout(function () {
|
|
||||||
var card = document.querySelector('.presence-card');
|
|
||||||
if (!card || card.hidden) {
|
|
||||||
var hint = document.querySelector('.api-empty');
|
|
||||||
if (hint) hint.classList.add('show');
|
|
||||||
}
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
306
api/index.html
306
api/index.html
|
|
@ -1,306 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Now-Playing API · clove</title>
|
|
||||||
|
|
||||||
<!-- pick the Catppuccin flavor before paint (?theme=, default mocha) -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var p = new URLSearchParams(location.search);
|
|
||||||
var t = p.get('theme');
|
|
||||||
var ok = ['mocha', 'macchiato', 'frappe', 'latte'];
|
|
||||||
document.documentElement.setAttribute('data-theme', ok.indexOf(t) >= 0 ? t : 'mocha');
|
|
||||||
// card mode if a user id was passed, else docs mode
|
|
||||||
var id = p.get('u') || p.get('id') || p.get('user') ||
|
|
||||||
(/^#\d{5,25}$/.test(location.hash) ? location.hash.slice(1) : '');
|
|
||||||
document.documentElement.setAttribute('data-mode', /^\d{5,25}$/.test(id) ? 'card' : 'docs');
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
|
||||||
<link rel="stylesheet" href="/css/fonts.css">
|
|
||||||
<link rel="stylesheet" href="/api/api.css">
|
|
||||||
|
|
||||||
<meta name="description" content="A tiny live Discord presence API — drop in a user ID and get a Now-Playing card. Powered by Lanyard.">
|
|
||||||
<meta property="og:title" content="Now-Playing API · clove">
|
|
||||||
<meta property="og:description" content="Drop in a Discord user ID, get a live Now-Playing card. Free for friends.">
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:url" content="https://clove.is-a.dev/api">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ---- docs-page chrome (the card itself lives in api.css) ---- */
|
|
||||||
html[data-mode="card"] .docs { display: none; }
|
|
||||||
html[data-mode="docs"] .api-stage { display: none; }
|
|
||||||
|
|
||||||
.docs {
|
|
||||||
max-width: 760px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 3.2rem 1.25rem 5rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.docs h1 {
|
|
||||||
font-size: clamp(1.8rem, 5vw, 2.6rem);
|
|
||||||
margin: 0 0 0.3rem;
|
|
||||||
color: rgb(var(--accent-rgb));
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
.docs .lede { color: var(--subtext-1); font-size: 1.05rem; margin: 0 0 2rem; }
|
|
||||||
.docs h2 {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
margin: 2.4rem 0 0.7rem;
|
|
||||||
color: var(--text);
|
|
||||||
border-bottom: 1px solid var(--surface-0);
|
|
||||||
padding-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.docs p { color: var(--subtext-1); margin: 0.6rem 0; }
|
|
||||||
.docs a { color: rgb(var(--accent-rgb)); }
|
|
||||||
.docs ol, .docs ul { color: var(--subtext-1); padding-left: 1.3rem; }
|
|
||||||
.docs li { margin: 0.3rem 0; }
|
|
||||||
.docs code {
|
|
||||||
font-family: 'Comic Code', ui-monospace, monospace;
|
|
||||||
background: var(--surface-0);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.88em;
|
|
||||||
}
|
|
||||||
.docs pre {
|
|
||||||
background: var(--crust);
|
|
||||||
border: 1px solid var(--surface-0);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.8rem 0;
|
|
||||||
}
|
|
||||||
.docs pre code { background: none; padding: 0; font-size: 0.82rem; color: var(--subtext-1); line-height: 1.5; }
|
|
||||||
.badge-pill {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--surface-0);
|
|
||||||
color: var(--subtext-0);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.15rem 0.7rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- the interactive try-it box ---- */
|
|
||||||
.tryit {
|
|
||||||
background: var(--mantle);
|
|
||||||
border: 1px solid var(--surface-0);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.1rem 1.2rem 1.3rem;
|
|
||||||
margin: 1rem 0 0.6rem;
|
|
||||||
}
|
|
||||||
.tryit label { display: block; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--subtext-0); margin-bottom: 0.4rem; }
|
|
||||||
.tryit-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
||||||
.tryit input {
|
|
||||||
flex: 1; min-width: 200px;
|
|
||||||
background: var(--crust);
|
|
||||||
border: 1px solid var(--surface-1);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.tryit input:focus { border-color: rgb(var(--accent-rgb)); }
|
|
||||||
.tryit button {
|
|
||||||
background: rgb(var(--accent-rgb));
|
|
||||||
color: var(--crust);
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.12s ease, filter 0.12s ease;
|
|
||||||
}
|
|
||||||
.tryit button:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
|
||||||
.tryit .themes { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.8rem; }
|
|
||||||
.swatch {
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--surface-0);
|
|
||||||
color: var(--subtext-1);
|
|
||||||
}
|
|
||||||
.swatch[aria-pressed="true"] { border-color: rgb(var(--accent-rgb)); color: var(--text); }
|
|
||||||
.share { margin-top: 0.9rem; font-size: 0.82rem; color: var(--subtext-0); word-break: break-all; }
|
|
||||||
.share code { user-select: all; }
|
|
||||||
|
|
||||||
.home-link { display: inline-block; margin-top: 2.5rem; font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* card-mode "back to docs" affordance */
|
|
||||||
.api-stage .back {
|
|
||||||
position: fixed; top: 1rem; left: 1rem;
|
|
||||||
font-size: 0.8rem; color: var(--subtext-0); text-decoration: none;
|
|
||||||
background: var(--surface-0); padding: 0.3rem 0.7rem; border-radius: 999px;
|
|
||||||
}
|
|
||||||
.api-empty { display: none; }
|
|
||||||
html[data-mode="card"] .api-empty.show { display: block; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="api-body">
|
|
||||||
|
|
||||||
<!-- ========================= CARD MODE (?u=ID) ========================= -->
|
|
||||||
<div class="api-stage">
|
|
||||||
<a class="back" href="/api">← API docs</a>
|
|
||||||
<div id="now-playing"></div>
|
|
||||||
<p class="api-empty">
|
|
||||||
No presence yet. If this stays empty, the user probably hasn't joined
|
|
||||||
<a href="https://discord.gg/lanyard" target="_blank" rel="noopener">discord.gg/lanyard</a>
|
|
||||||
yet — that's what lets the card track them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ========================= DOCS MODE ========================= -->
|
|
||||||
<main class="docs">
|
|
||||||
<span class="badge-pill">live · free for friends</span>
|
|
||||||
<h1>Now-Playing API</h1>
|
|
||||||
<p class="lede">
|
|
||||||
The same Discord presence card from my homepage — but pointed at <em>your</em>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="tryit">
|
|
||||||
<label for="uid">Your Discord user ID</label>
|
|
||||||
<div class="tryit-row">
|
|
||||||
<input id="uid" inputmode="numeric" placeholder="e.g. 1464890289922641993" autocomplete="off">
|
|
||||||
<button id="go" type="button">Show my card →</button>
|
|
||||||
</div>
|
|
||||||
<div class="themes" id="themes" role="group" aria-label="theme">
|
|
||||||
<button class="swatch" data-t="mocha" aria-pressed="true">Mocha</button>
|
|
||||||
<button class="swatch" data-t="macchiato" aria-pressed="false">Macchiato</button>
|
|
||||||
<button class="swatch" data-t="frappe" aria-pressed="false">Frappé</button>
|
|
||||||
<button class="swatch" data-t="latte" aria-pressed="false">Latte</button>
|
|
||||||
</div>
|
|
||||||
<div class="share" id="share" hidden>Share link: <code id="shareurl"></code></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>1. Join Lanyard</h2>
|
|
||||||
<p>
|
|
||||||
The card reads your presence through <a href="https://github.com/Phineas/lanyard" target="_blank" rel="noopener">Lanyard</a>,
|
|
||||||
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:
|
|
||||||
<a href="https://discord.gg/lanyard" target="_blank" rel="noopener">discord.gg/lanyard</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>2. Grab your user ID</h2>
|
|
||||||
<p>
|
|
||||||
In Discord: <strong>Settings → Advanced → Developer Mode</strong> (turn it on),
|
|
||||||
then right-click your name and <strong>Copy User ID</strong>. It's a long number
|
|
||||||
like <code>1464890289922641993</code>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>3. Use your card</h2>
|
|
||||||
<p>Three ways to point it at your ID — all do the same thing:</p>
|
|
||||||
<pre><code>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</code></pre>
|
|
||||||
<p>Pick a theme by adding <code>?theme=</code> — <code>mocha</code> (default),
|
|
||||||
<code>macchiato</code>, <code>frappe</code>, or <code>latte</code>:</p>
|
|
||||||
<pre><code>https://clove.is-a.dev/api/YOUR_ID?theme=latte</code></pre>
|
|
||||||
|
|
||||||
<h2>Embed it anywhere</h2>
|
|
||||||
<p>Drop it into Notion, a website, an OBS browser source, a README iframe — anywhere that takes HTML or a URL:</p>
|
|
||||||
<pre><code><iframe
|
|
||||||
src="https://clove.is-a.dev/api/YOUR_ID"
|
|
||||||
style="border:0;width:340px;height:360px"
|
|
||||||
title="my Discord presence"></iframe></code></pre>
|
|
||||||
<p>
|
|
||||||
Want it on your own page without the iframe? Add a mount + the two files and
|
|
||||||
pass your ID via <code>data-user</code>:
|
|
||||||
</p>
|
|
||||||
<pre><code><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></code></pre>
|
|
||||||
|
|
||||||
<h2>What it shows</h2>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Notes & limits</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Everything is read-only and public — it only shows what Lanyard already exposes for your account.</li>
|
|
||||||
<li>Discord doesn't share activity button <em>URLs</em>, so those show as plain labels.</li>
|
|
||||||
<li>If you leave the Lanyard server, the card goes quiet.</li>
|
|
||||||
<li>It's a static page calling Lanyard's public API directly from your browser — nothing of yours is stored here.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<a class="home-link" href="/">← back to clove.is-a.dev</a>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/api/now-playing.js"></script>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var input = document.getElementById('uid');
|
|
||||||
var go = document.getElementById('go');
|
|
||||||
var themes = document.getElementById('themes');
|
|
||||||
var share = document.getElementById('share');
|
|
||||||
var shareurl = document.getElementById('shareurl');
|
|
||||||
var theme = document.documentElement.getAttribute('data-theme') || 'mocha';
|
|
||||||
|
|
||||||
function clean(v) { return (v || '').replace(/\D/g, ''); }
|
|
||||||
function update() {
|
|
||||||
var id = clean(input.value);
|
|
||||||
if (id.length >= 5) {
|
|
||||||
var url = location.origin + '/api/' + id + (theme !== 'mocha' ? '?theme=' + theme : '');
|
|
||||||
shareurl.textContent = url;
|
|
||||||
share.hidden = false;
|
|
||||||
} else {
|
|
||||||
share.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function submit() {
|
|
||||||
var id = clean(input.value);
|
|
||||||
if (id.length < 5) { input.focus(); return; }
|
|
||||||
location.href = '/api/?u=' + id + (theme !== 'mocha' ? '&theme=' + theme : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
go.addEventListener('click', submit);
|
|
||||||
input.addEventListener('input', update);
|
|
||||||
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
|
|
||||||
|
|
||||||
themes.addEventListener('click', function (e) {
|
|
||||||
var b = e.target.closest('.swatch'); if (!b) return;
|
|
||||||
theme = b.dataset.t;
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
Array.prototype.forEach.call(themes.children, function (c) {
|
|
||||||
c.setAttribute('aria-pressed', c === b ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
update();
|
|
||||||
});
|
|
||||||
|
|
||||||
// sync swatch highlight to the active theme on load
|
|
||||||
Array.prototype.forEach.call(themes.children, function (c) {
|
|
||||||
c.setAttribute('aria-pressed', c.dataset.t === theme ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
|
|
||||||
// card mode: reveal the "not tracked" hint if nothing renders in time
|
|
||||||
if (document.documentElement.getAttribute('data-mode') === 'card') {
|
|
||||||
setTimeout(function () {
|
|
||||||
var card = document.querySelector('.presence-card');
|
|
||||||
if (!card || card.hidden) {
|
|
||||||
var hint = document.querySelector('.api-empty');
|
|
||||||
if (hint) hint.classList.add('show');
|
|
||||||
}
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
@ -6,11 +6,6 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Cool People</title>
|
<title>Cool People</title>
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
<link rel="stylesheet" href="/css/themes/mocha.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/macchiato.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/frappe.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/latte.css">
|
|
||||||
<link rel="stylesheet" href="/api/api.css">
|
|
||||||
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
||||||
|
|
||||||
|
|
@ -53,6 +48,7 @@
|
||||||
<a class="nav-link selected" data-href="/cool-people">Cool People</a>
|
<a class="nav-link selected" data-href="/cool-people">Cool People</a>
|
||||||
<a class="nav-link" data-href="/dev-info">Dev Info</a>
|
<a class="nav-link" data-href="/dev-info">Dev Info</a>
|
||||||
<a class="nav-link" data-href="/discord-bots">Discord Bots</a>
|
<a class="nav-link" data-href="/discord-bots">Discord Bots</a>
|
||||||
|
<a class="nav-link" data-href="/music">Music</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -171,7 +167,7 @@
|
||||||
|
|
||||||
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/api/now-playing.js" data-user="1464890289922641993"></script>
|
<script src="/js/now-playing.js" data-user="1464890289922641993"></script>
|
||||||
<script src="/js/flavors.js"></script>
|
<script src="/js/flavors.js"></script>
|
||||||
<script src="/js/dev-mode.js"></script>
|
<script src="/js/dev-mode.js"></script>
|
||||||
<script src="/js/terminal.js"></script>
|
<script src="/js/terminal.js"></script>
|
||||||
|
|
|
||||||
252
css/main.css
252
css/main.css
|
|
@ -23,7 +23,7 @@
|
||||||
@import url(/css/themes/latte.css);
|
@import url(/css/themes/latte.css);
|
||||||
@import url(/css/themes/mocha.css);
|
@import url(/css/themes/mocha.css);
|
||||||
@import url(/css/themes/macchiato.css);
|
@import url(/css/themes/macchiato.css);
|
||||||
@import url(/api/api.css);
|
@import url(/css/player.css);
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
1. BASE & RESET
|
1. BASE & RESET
|
||||||
|
|
@ -2181,3 +2181,253 @@ body:has(.friend-grid) .hub-header {
|
||||||
.friend:hover .friend-pfp {
|
.friend:hover .friend-pfp {
|
||||||
filter: none;
|
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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
* <html data-theme="..."> 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
|
|
||||||
* <div id="now-playing"></div> and get a live Discord presence card.
|
|
||||||
* ===================================================================== */
|
|
||||||
|
|
||||||
|
|
||||||
/* =====================================================================
|
|
||||||
* 1. THEMES — Catppuccin (https://github.com/catppuccin/catppuccin)
|
|
||||||
* Only applied when <html data-theme="..."> is set (the API pages do
|
|
||||||
* this from the ?theme= param, defaulting to mocha).
|
|
||||||
* ===================================================================== */
|
|
||||||
html[data-theme="mocha"] {
|
html[data-theme="mocha"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--rosewater: #f5e0dc; --flamingo: #f2cdcd; --pink: #f5c2e7;
|
--rosewater: #f5e0dc; --flamingo: #f2cdcd; --pink: #f5c2e7;
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
<a class="nav-link" data-href="/cool-people">Cool People</a>
|
<a class="nav-link" data-href="/cool-people">Cool People</a>
|
||||||
<a class="nav-link selected" data-href="/dev-info">Dev Info</a>
|
<a class="nav-link selected" data-href="/dev-info">Dev Info</a>
|
||||||
<a class="nav-link" data-href="/discord-bots">Discord Bots</a>
|
<a class="nav-link" data-href="/discord-bots">Discord Bots</a>
|
||||||
|
<a class="nav-link" data-href="/music">Music</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -226,7 +227,7 @@
|
||||||
|
|
||||||
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/api/now-playing.js" data-user="1464890289922641993"></script>
|
<script src="/js/now-playing.js" data-user="1464890289922641993"></script>
|
||||||
<script src="/js/flavors.js"></script>
|
<script src="/js/flavors.js"></script>
|
||||||
<script src="/js/dev-mode.js"></script>
|
<script src="/js/dev-mode.js"></script>
|
||||||
<script src="/js/dev-info.js"></script>
|
<script src="/js/dev-info.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,6 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Clove Twilight - Discord Bots</title>
|
<title>Clove Twilight - Discord Bots</title>
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
<link rel="stylesheet" href="/css/themes/mocha.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/macchiato.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/frappe.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/latte.css">
|
|
||||||
<link rel="stylesheet" href="/api/api.css">
|
|
||||||
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
||||||
|
|
||||||
|
|
@ -55,6 +50,7 @@
|
||||||
<a class="nav-link" data-href="/cool-people">Cool People</a>
|
<a class="nav-link" data-href="/cool-people">Cool People</a>
|
||||||
<a class="nav-link" data-href="/dev-info">Dev Info</a>
|
<a class="nav-link" data-href="/dev-info">Dev Info</a>
|
||||||
<a class="nav-link selected" data-href="/discord-bots">Discord Bots</a>
|
<a class="nav-link selected" data-href="/discord-bots">Discord Bots</a>
|
||||||
|
<a class="nav-link" data-href="/music">Music</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -218,7 +214,7 @@
|
||||||
|
|
||||||
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/api/now-playing.js" data-user="1464890289922641993"></script>
|
<script src="/js/now-playing.js" data-user="1464890289922641993"></script>
|
||||||
<script src="/js/flavors.js"></script>
|
<script src="/js/flavors.js"></script>
|
||||||
<script src="/js/dev-mode.js"></script>
|
<script src="/js/dev-mode.js"></script>
|
||||||
<script src="/js/site-switcher.js"></script>
|
<script src="/js/site-switcher.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,6 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Clove Twilight - Link Center</title>
|
<title>Clove Twilight - Link Center</title>
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
<link rel="stylesheet" href="/css/themes/mocha.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/macchiato.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/frappe.css">
|
|
||||||
<link rel="stylesheet" href="/css/themes/latte.css">
|
|
||||||
<link rel="stylesheet" href="/api/api.css">
|
|
||||||
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
||||||
|
|
||||||
|
|
@ -52,6 +47,7 @@
|
||||||
<a class="nav-link" data-href="/cool-people">Cool People</a>
|
<a class="nav-link" data-href="/cool-people">Cool People</a>
|
||||||
<a class="nav-link" data-href="/dev-info">Dev Info</a>
|
<a class="nav-link" data-href="/dev-info">Dev Info</a>
|
||||||
<a class="nav-link" data-href="/discord-bots">Discord Bots</a>
|
<a class="nav-link" data-href="/discord-bots">Discord Bots</a>
|
||||||
|
<a class="nav-link" data-href="/music">Music</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -101,7 +97,7 @@
|
||||||
|
|
||||||
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/api/now-playing.js" data-user="1464890289922641993"></script>
|
<script src="/js/now-playing.js" data-user="1464890289922641993"></script>
|
||||||
<script src="/js/flavors.js"></script>
|
<script src="/js/flavors.js"></script>
|
||||||
<script src="/js/dev-mode.js"></script>
|
<script src="/js/dev-mode.js"></script>
|
||||||
<script src="/js/terminal.js"></script>
|
<script src="/js/terminal.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -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, """).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 = '<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) || " ") + "</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) || " ") + "</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));
|
||||||
|
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 =
|
||||||
|
'<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">Couldn’t 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);
|
||||||
|
})();
|
||||||
|
|
@ -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/<id>
|
|
||||||
* 2. the query ?u=<id> (also ?id= / ?user=)
|
|
||||||
* 3. the hash #<id>
|
|
||||||
* 4. <script data-user="<id>"> or <div id="now-playing" data-user="...">
|
|
||||||
* 5. window.NOW_PLAYING_USER_ID
|
|
||||||
* If none resolve, the script does nothing (lets a docs page show through).
|
|
||||||
*
|
|
||||||
* REQUIREMENT: the user must be in the Lanyard Discord (discord.gg/lanyard)
|
|
||||||
* so their presence is tracked. See /api for the full how-to.
|
|
||||||
*
|
|
||||||
* The mount keeps id="now-playing" so other scripts can anchor to it.
|
|
||||||
* ===================================================================== */
|
|
||||||
(function presence() {
|
(function presence() {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Music · Clove Twilight</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg">
|
||||||
|
|
||||||
|
<!-- SEO Meta Tags -->
|
||||||
|
<meta name="description" content="What Clove Twilight is listening to — live now-playing, synced lyrics, and recent plays." />
|
||||||
|
<meta name="author" content="doughmination" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<link rel="canonical" href="https://clove.is-a.dev/music" />
|
||||||
|
<meta name="theme-color" content="#f5c2e7" />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:image" content="https://clove.is-a.dev/assets/favicon/favicon.png" />
|
||||||
|
<meta property="og:site_name" content="clove.is-a.dev" />
|
||||||
|
<meta property="og:title" content="Music · Clove Twilight" />
|
||||||
|
<meta property="og:description" content="Live now-playing, synced lyrics, and recent plays." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://clove.is-a.dev/music" />
|
||||||
|
<meta property="og:locale" content="en_GB" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="nav">
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a class="nav-link" data-href="/">Link Center</a>
|
||||||
|
<a class="nav-link" data-href="/cool-people">Cool People</a>
|
||||||
|
<a class="nav-link" data-href="/dev-info">Dev Info</a>
|
||||||
|
<a class="nav-link" data-href="/discord-bots">Discord Bots</a>
|
||||||
|
<a class="nav-link selected" data-href="/music">Music</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="music-wrap" id="music">
|
||||||
|
<header class="music-head">
|
||||||
|
<h1>Music</h1>
|
||||||
|
<p>What I'm listening to, live — with lyrics that follow along.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- now playing -->
|
||||||
|
<a class="mnp" id="np-link" target="_blank" rel="noopener">
|
||||||
|
<img class="mnp-art" id="np-art" alt="">
|
||||||
|
<div class="mnp-meta">
|
||||||
|
<span class="mnp-state" id="np-state">Connecting…</span>
|
||||||
|
<span class="mnp-title" id="np-title">—</span>
|
||||||
|
<span class="mnp-artist" id="np-artist"></span>
|
||||||
|
<span class="mnp-album" id="np-album"></span>
|
||||||
|
<div class="mnp-progress" id="np-progress" hidden>
|
||||||
|
<span class="mnp-time" id="np-cur">0:00</span>
|
||||||
|
<span class="mnp-bar"><span class="mnp-fill" id="np-fill"></span></span>
|
||||||
|
<span class="mnp-time" id="np-dur">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- lyrics -->
|
||||||
|
<div class="sec-row">
|
||||||
|
<h2 class="sec-title">Lyrics</h2>
|
||||||
|
<button class="ly-lock is-locked" id="ly-lock" type="button" aria-pressed="true" hidden>Following</button>
|
||||||
|
</div>
|
||||||
|
<div class="lyrics is-empty" id="lyrics"><p class="ly-note">Waiting for a track…</p></div>
|
||||||
|
|
||||||
|
<!-- recently played -->
|
||||||
|
<h2 class="sec-title">Recently played</h2>
|
||||||
|
<ul class="recent" id="recent"></ul>
|
||||||
|
|
||||||
|
<!-- top artists (hidden until Last.fm data arrives) -->
|
||||||
|
<section id="top" hidden></section>
|
||||||
|
|
||||||
|
<a class="music-back" data-href="/">← back to clove.is-a.dev</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
||||||
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/music.js"></script>
|
||||||
|
<script src="/js/flavors.js"></script>
|
||||||
|
<script src="/js/site-switcher.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue