diff --git a/404.html b/404.html new file mode 100644 index 0000000..0cdce28 --- /dev/null +++ b/404.html @@ -0,0 +1,92 @@ + + + + + + + doughmination + + + + + + + + + + + + + +
+ ← 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/api.css b/api/api.css new file mode 100644 index 0000000..b89a008 --- /dev/null +++ b/api/api.css @@ -0,0 +1,532 @@ +/* ===================================================================== + * 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; + --accent-rgb: 245, 194, 231; + --mauve: #cba6f7; --red: #f38ba8; --maroon: #eba0ac; --peach: #fab387; + --yellow: #f9e2af; --green: #a6e3a1; --teal: #94e2d5; --sky: #89dceb; + --saphire: #74c7ec; --blue: #89b4fa; --lavender: #b4befe; + --text: #cdd6f4; --subtext-0: #a6adc8; --subtext-1: #bac2de; + --overlay-0: #6c7086; --overlay-1: #7f849c; --overlay-2: #9399b2; + --surface-0: #313244; --surface-1: #45475a; --surface-2: #585b70; + --base: #1e1e2e; --mantle: #181825; --crust: #11111b; +} + +html[data-theme="macchiato"] { + color-scheme: dark; + --rosewater: #f4dbd6; --flamingo: #f0c6c6; --pink: #f5bde6; + --accent-rgb: 245, 189, 230; + --mauve: #c6a0f6; --red: #ed8796; --maroon: #ee99a0; --peach: #f5a97f; + --yellow: #eed49f; --green: #a6da95; --teal: #8bd5ca; --sky: #91d7e3; + --saphire: #7dc4e4; --blue: #8aadf4; --lavender: #b7bdf8; + --text: #cad3f5; --subtext-0: #a5adcb; --subtext-1: #b8c0e0; + --overlay-0: #6e738d; --overlay-1: #8087a2; --overlay-2: #939ab7; + --surface-0: #363a4f; --surface-1: #494d64; --surface-2: #5b6078; + --base: #24273a; --mantle: #1e2030; --crust: #181926; +} + +html[data-theme="frappe"] { + color-scheme: dark; + --rosewater: #f2d5cf; --flamingo: #eebebe; --pink: #f4b8e4; + --accent-rgb: 244, 184, 228; + --mauve: #ca9ee6; --red: #e78284; --maroon: #ea999c; --peach: #ef9f76; + --yellow: #e5c890; --green: #a6d189; --teal: #81c8be; --sky: #99d1db; + --saphire: #85c1dc; --blue: #8caaee; --lavender: #babbf1; + --text: #c6d0f5; --subtext-0: #a5adce; --subtext-1: #b5bfe2; + --overlay-0: #737994; --overlay-1: #838ba7; --overlay-2: #949cbb; + --surface-0: #414559; --surface-1: #51576d; --surface-2: #626880; + --base: #303446; --mantle: #292c3c; --crust: #232634; +} + +html[data-theme="latte"] { + color-scheme: light; + --rosewater: #dc8a78; --flamingo: #dd7878; --pink: #ea76cb; + --accent-rgb: 234, 118, 203; + --mauve: #8839ef; --red: #d20f39; --maroon: #e64553; --peach: #fe640b; + --yellow: #df8e1d; --green: #40a02b; --teal: #179299; --sky: #04a5e5; + --saphire: #209fb5; --blue: #1e66f5; --lavender: #7287fd; + --text: #4c4f69; --subtext-0: #6c6f85; --subtext-1: #5c5f77; + --overlay-0: #9ca0b0; --overlay-1: #8c8fa1; --overlay-2: #7c7f93; + --surface-0: #ccd0da; --surface-1: #bcc0cc; --surface-2: #acb0be; + --base: #eff1f5; --mantle: #e6e9ef; --crust: #dce0e8; +} + + +/* ===================================================================== + * 2. STANDALONE PAGE STAGE + * Only the dedicated /api pages use .api-stage; it centers a single + * card on a Catppuccin gradient. The homepage never sets data-theme + * or .api-stage, so its fixed top-left card is untouched. + * ===================================================================== */ +html[data-theme] body.api-body { + margin: 0; + min-height: 100vh; + font-family: 'Comic Code', ui-monospace, system-ui, sans-serif; + color: var(--text); + background: linear-gradient(135deg, var(--base) 0%, var(--mantle) 60%, var(--crust) 100%); +} + +.api-stage { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +/* On the stage, the card sits in flow & centered instead of fixed. */ +.api-stage .presence-card { + position: static; + margin: 0; + max-width: 320px; + width: 100%; +} + +.api-empty { + text-align: center; + color: var(--subtext-0); + font-size: 0.9rem; + max-width: 420px; + line-height: 1.5; +} +.api-empty a { color: rgb(var(--accent-rgb)); } + + +/* ===================================================================== + * 3. PRESENCE CARD (unified Discord-style profile pill) + * Lifted verbatim from main.css §13 so the card is self-contained. + * ===================================================================== */ +.presence-card { + --np-accent: 245, 194, 231; + position: fixed; + top: 1rem; + left: 1rem; + z-index: 6; + width: max-content; + max-width: 280px; + background: var(--surface-0); + border: 1px solid var(--surface-1); + border-radius: 16px; + box-shadow: 0 8px 26px -12px rgba(17, 17, 27, 0.7); + overflow: hidden; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.presence-card[hidden] { display: none; } + +.presence-card.has-accent { + border-color: rgba(var(--np-accent), 0.5); + box-shadow: 0 8px 26px -12px rgba(var(--np-accent), 0.6); +} + +/* ---- header (always visible) ---- */ +.pc-head { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.7rem; +} + +.pc-avatar { + position: relative; + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.pc-av-img { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + display: block; +} + +/* avatar decoration / frame overlay (Discord cosmetic) */ +.pc-av-deco { + position: absolute; + top: 50%; + left: 50%; + width: 54px; + height: 54px; + transform: translate(-50%, -50%); + pointer-events: none; +} +.pc-av-deco[hidden] { display: none; } + +.pc-status { + position: absolute; + right: -1px; + bottom: -1px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2.5px solid var(--surface-0); + background: var(--overlay-0); +} +.presence-card[data-status="online"] .pc-status { background: var(--green); } +.presence-card[data-status="idle"] .pc-status { background: var(--yellow); } +.presence-card[data-status="dnd"] .pc-status { background: var(--red); } +.presence-card[data-status="offline"] .pc-status { background: var(--overlay-0); } + +.pc-id { + display: flex; + flex-direction: column; + gap: 0.05rem; + min-width: 0; +} +.pc-name { + font-size: 0.92rem; + font-weight: 700; + color: rgb(var(--accent-rgb)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 0.5s ease; +} +.pc-user { + font-size: 0.7rem; + color: var(--subtext-0); + white-space: nowrap; +} +.pc-user:empty { display: none; } + +/* ---- expandable sections ---- */ +.pc-sections { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0 0.6rem 0.6rem; + transition: opacity 0.2s ease; +} +.presence-card:not(.has-sections) .pc-sections { display: none; } + +.pc-row { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.4rem 0.5rem; + border-radius: 10px; + background: var(--mantle); + border: 1px solid transparent; + color: var(--text); + text-decoration: none; + transition: border-color 0.15s ease, transform 0.15s ease; +} +a.pc-row:hover, +.pc-row--stack:hover { + border-color: rgba(var(--np-accent), 0.55); + transform: translateX(2px); +} + +.pc-row-text { + display: flex; + flex-direction: column; + gap: 0.04rem; + min-width: 0; +} +.pc-row-kind { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--subtext-0); +} +.pc-row-title { + font-size: 0.8rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} +.pc-row-sub { + font-size: 0.7rem; + color: var(--subtext-0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} +.pc-row-title:empty, .pc-row-sub:empty { display: none; } + +.pc-row-elapsed { + font-size: 0.62rem; + color: var(--subtext-0); + margin-top: 0.1rem; +} +.pc-row-elapsed:empty { display: none; } + +/* artwork / icons */ +.pc-art, +.pc-row-ic-img { + width: 38px; + height: 38px; + border-radius: 7px; + object-fit: cover; + flex-shrink: 0; +} +.pc-row-ic.pc-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + margin: 0 0.5rem; + background: rgb(var(--accent-rgb)); +} +.pc-dev .pc-row-ic.pc-dot { background: var(--blue); border-radius: 2px; } +.pc-game .pc-row-ic.pc-dot { background: var(--green); } +.pc-stream .pc-row-ic.pc-dot{ background: var(--mauve); } + +/* custom status */ +.pc-custom { + background: transparent; + border: none; + padding: 0.1rem 0.5rem; + gap: 0.4rem; + align-items: flex-start; +} +.pc-emoji { width: 18px; height: 18px; flex-shrink: 0; margin-top: 0.05rem; } +.pc-custom-text { + font-size: 0.74rem; + color: var(--subtext-0); + max-width: 230px; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; +} + +/* spotify progress */ +.pc-spotify .pc-row-title { color: var(--green); } +.pc-progress { + display: flex; + flex-direction: column; + gap: 0.15rem; + margin-top: 0.25rem; + width: 180px; +} +.pc-bar { + height: 4px; + border-radius: 999px; + background: var(--surface-1); + overflow: hidden; +} +.pc-fill { + display: block; + height: 100%; + width: 0; + border-radius: 999px; + background: rgb(var(--np-accent)); +} +.pc-times { + display: flex; + justify-content: space-between; + font-size: 0.58rem; + color: var(--subtext-0); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 640px) { + .presence-card { max-width: calc(100vw - 2rem); } + .api-stage .presence-card { max-width: 100%; } +} + +/* ---- extended Lanyard fields ---- */ +.pc-name-row { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; +} +.pc-name-row .pc-name { min-width: 0; } + +/* gradient display name (display_name_styles) */ +.pc-name.is-gradient { + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; +} + +/* server tag chip (primary_guild) */ +.pc-tag { + display: inline-flex; + align-items: center; + gap: 0.2rem; + flex-shrink: 0; + padding: 0.05rem 0.35rem; + border-radius: 6px; + background: var(--surface-2); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--text); +} +.pc-tag[hidden] { display: none; } +.pc-tag-badge { width: 14px; height: 14px; display: block; } + +/* username + active-platform indicators */ +.pc-sub-row { + display: flex; + align-items: center; + gap: 0.35rem; +} +.pc-platforms { + display: inline-flex; + align-items: center; + gap: 0.2rem; + color: var(--subtext-0); +} +.pc-plat { width: 12px; height: 12px; display: inline-flex; } +.pc-plat svg { width: 12px; height: 12px; display: block; } + +/* KV meta line (location, etc.) */ +.pc-meta { + display: flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.1rem; + font-size: 0.66rem; + color: var(--subtext-0); +} +.pc-meta[hidden] { display: none; } +.pc-pin { font-size: 0.7rem; line-height: 1; } + +/* stacked rows (activity rows that carry buttons) */ +.pc-row--stack { + flex-direction: column; + align-items: stretch; + gap: 0.4rem; +} +.pc-row-link { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + color: var(--text); + text-decoration: none; +} + +/* activity icon with small corner badge (assets.small_image) */ +.pc-ic-wrap { + position: relative; + flex-shrink: 0; + width: 38px; + height: 38px; +} +.pc-ic-wrap .pc-row-ic-img { width: 38px; height: 38px; } +.pc-ic-badge { + position: absolute; + right: -3px; + bottom: -3px; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--mantle); + object-fit: cover; +} + +/* activity buttons (labels from presence) */ +.pc-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.pc-btn { + font-size: 0.66rem; + padding: 0.22rem 0.55rem; + border-radius: 6px; + background: var(--surface-1); + color: var(--text); + text-decoration: none; + border: 1px solid transparent; + transition: border-color 0.15s ease, background 0.15s ease; +} +.pc-btn:hover { + border-color: rgb(var(--np-accent)); + background: var(--surface-2); +} + +/* profile badges */ +.pc-badges { + display: inline-flex; + align-items: center; + gap: 0.2rem; + flex-wrap: wrap; + margin-top: 0.15rem; +} +.pc-badges:empty { display: none; } +.pc-badge { width: 16px; height: 16px; display: block; } +.pc-badge-link { display: inline-flex; line-height: 0; } + +/* wishlist star + panel */ +.pc-star { + margin-left: auto; + align-self: flex-start; + background: none; + border: none; + cursor: pointer; + font-size: 0.95rem; + line-height: 1; + color: var(--subtext-0); + padding: 0.1rem 0.15rem; + transition: color 0.15s ease, transform 0.15s ease; +} +.pc-star:hover { color: rgb(var(--accent-rgb)); transform: scale(1.12); } +.pc-star.on { color: var(--yellow); } + +.pc-wishlist { display: none; } +.presence-card.show-wishlist .pc-wishlist { + display: block; + border-top: 1px solid var(--surface-1); + margin: 0 0.6rem; + padding: 0.6rem 0 0.7rem; +} +.pc-wishlist-title { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--subtext-0); + margin-bottom: 0.45rem; +} +.pc-wl-item { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.25rem 0.4rem; + border-radius: 8px; + text-decoration: none; + color: var(--text); +} +a.pc-wl-item:hover { background: var(--mantle); } +.pc-wl-ic { width: 22px; height: 22px; border-radius: 5px; object-fit: cover; } +.pc-wl-name { font-size: 0.8rem; } +.pc-wl-empty { font-size: 0.78rem; color: var(--subtext-0); margin: 0; } + +/* Discord profile gradient (Catppuccin is the fallback) */ +.presence-card.has-profile-grad { + background: linear-gradient(180deg, rgb(var(--pc-grad-1-rgb)) 0%, rgb(var(--pc-grad-2-rgb)) 100%); +} +.presence-card.has-profile-grad:not(.has-accent) { + border-color: rgba(var(--pc-grad-1-rgb), 0.6); +} +.presence-card.has-profile-grad .pc-row { background: rgba(17, 17, 27, 0.55); } diff --git a/api/index.html b/api/index.html new file mode 100644 index 0000000..32793a7 --- /dev/null +++ b/api/index.html @@ -0,0 +1,306 @@ + + + + + + + Now-Playing API · doughmination + + + + + + + + + + + + + + + + + + + + +
+ ← 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://doughmination.is-a.dev/api/YOUR_ID      ← prettiest
+https://doughmination.is-a.dev/api/?u=YOUR_ID
+https://doughmination.is-a.dev/api/#YOUR_ID
+

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

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