add api and also use new player
This commit is contained in:
parent
8627348c7e
commit
2783af10fa
|
|
@ -0,0 +1,92 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>doughmination</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>
|
||||||
|
|
@ -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
|
||||||
|
* <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"] {
|
||||||
|
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); }
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
<!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 · doughmination</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 · doughmination">
|
||||||
|
<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://doughmination.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://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</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://doughmination.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://doughmination.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://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></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 doughmination.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>
|
||||||
|
|
@ -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/<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() {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ---- who are we showing? ------------------------------------------------
|
||||||
|
function valid(id) { return typeof id === "string" && /^\d{5,25}$/.test(id); }
|
||||||
|
function resolveUserId() {
|
||||||
|
const path = location.pathname.match(/\/api\/(\d{5,25})(?:[\/?#]|$)/);
|
||||||
|
if (path) return path[1];
|
||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
const q = qs.get("u") || qs.get("id") || qs.get("user");
|
||||||
|
if (valid(q)) return q;
|
||||||
|
if (/^#\d{5,25}$/.test(location.hash)) return location.hash.slice(1);
|
||||||
|
const script = document.currentScript || document.querySelector("script[data-user]");
|
||||||
|
if (script && script.dataset && valid(script.dataset.user)) return script.dataset.user;
|
||||||
|
const m = document.getElementById("now-playing");
|
||||||
|
if (m && m.dataset && valid(m.dataset.user)) return m.dataset.user;
|
||||||
|
if (valid(window.NOW_PLAYING_USER_ID)) return window.NOW_PLAYING_USER_ID;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISCORD_USER_ID = resolveUserId();
|
||||||
|
const mount = document.getElementById("now-playing");
|
||||||
|
if (!mount || !DISCORD_USER_ID) return;
|
||||||
|
|
||||||
|
// ---- theme: only on standalone api pages (homepage uses data-flavor) ----
|
||||||
|
if (!document.documentElement.getAttribute("data-flavor")) {
|
||||||
|
const t = new URLSearchParams(location.search).get("theme");
|
||||||
|
const themes = ["mocha", "macchiato", "frappe", "latte"];
|
||||||
|
if (!document.documentElement.getAttribute("data-theme")) {
|
||||||
|
document.documentElement.setAttribute("data-theme", themes.indexOf(t) >= 0 ? t : "mocha");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- build the card -----------------------------------------------------
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.id = "now-playing";
|
||||||
|
card.className = "presence-card";
|
||||||
|
card.hidden = true;
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="pc-head">' +
|
||||||
|
'<span class="pc-avatar">' +
|
||||||
|
'<img class="pc-av-img" alt="" referrerpolicy="no-referrer" crossorigin="anonymous">' +
|
||||||
|
'<img class="pc-av-deco" alt="" aria-hidden="true" hidden>' +
|
||||||
|
'<span class="pc-status" aria-hidden="true"></span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="pc-id">' +
|
||||||
|
'<span class="pc-name-row">' +
|
||||||
|
'<span class="pc-name"></span>' +
|
||||||
|
'<span class="pc-tag" hidden></span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="pc-sub-row">' +
|
||||||
|
'<span class="pc-user"></span>' +
|
||||||
|
'<span class="pc-platforms" aria-hidden="true"></span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="pc-meta" hidden></span>' +
|
||||||
|
'<span class="pc-badges" aria-hidden="true"></span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<button class="pc-star" type="button" aria-label="show wishlist" title="wishlist">★</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="pc-sections"></div>' +
|
||||||
|
'<div class="pc-wishlist" id="pc-wishlist"></div>';
|
||||||
|
mount.replaceWith(card);
|
||||||
|
|
||||||
|
const avImg = card.querySelector(".pc-av-img");
|
||||||
|
const avDeco = card.querySelector(".pc-av-deco");
|
||||||
|
const nameEl = card.querySelector(".pc-name");
|
||||||
|
const tagEl = card.querySelector(".pc-tag");
|
||||||
|
const userEl = card.querySelector(".pc-user");
|
||||||
|
const platformsEl = card.querySelector(".pc-platforms");
|
||||||
|
const metaEl = card.querySelector(".pc-meta");
|
||||||
|
const badgesEl = card.querySelector(".pc-badges");
|
||||||
|
const sections = card.querySelector(".pc-sections");
|
||||||
|
const starBtn = card.querySelector(".pc-star");
|
||||||
|
const wishlistEl = card.querySelector(".pc-wishlist");
|
||||||
|
|
||||||
|
// ---- wishlist (revealed by the star) ------------------------------------
|
||||||
|
let wishlistItems = null;
|
||||||
|
function renderWishlist() {
|
||||||
|
if (!wishlistEl) return;
|
||||||
|
if (wishlistItems && wishlistItems.length) {
|
||||||
|
wishlistEl.innerHTML = '<div class="pc-wishlist-title">Wishlist</div>' +
|
||||||
|
wishlistItems.map(function (w) {
|
||||||
|
const inner =
|
||||||
|
(w.icon ? '<img class="pc-wl-ic" src="' + esc(w.icon) + '" alt="">' : "") +
|
||||||
|
'<span class="pc-wl-name">' + esc(w.name || "") + "</span>";
|
||||||
|
return w.url
|
||||||
|
? '<a class="pc-wl-item" href="' + esc(w.url) + '" target="_blank" rel="noopener">' + inner + "</a>"
|
||||||
|
: '<span class="pc-wl-item">' + inner + "</span>";
|
||||||
|
}).join("");
|
||||||
|
} else {
|
||||||
|
wishlistEl.innerHTML = '<div class="pc-wishlist-title">Wishlist</div>' +
|
||||||
|
'<p class="pc-wl-empty">coming soon ✨</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (starBtn) {
|
||||||
|
starBtn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = card.classList.toggle("show-wishlist");
|
||||||
|
starBtn.classList.toggle("on", open);
|
||||||
|
starBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
if (open) renderWishlist();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest = null;
|
||||||
|
let ticker = null;
|
||||||
|
let ws = null;
|
||||||
|
let heartbeat = null;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
|
||||||
|
// ---- small helpers ------------------------------------------------------
|
||||||
|
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 elapsedStr(start) {
|
||||||
|
const s = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
return h ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
function clamp(n, lo, hi) { return Math.min(Math.max(n, lo), hi); }
|
||||||
|
|
||||||
|
function avatarUrl(u) {
|
||||||
|
if (!u || !u.avatar) return "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
|
const ext = String(u.avatar).startsWith("a_") ? "gif" : "png";
|
||||||
|
return `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.${ext}?size=128`;
|
||||||
|
}
|
||||||
|
function emojiUrl(e) {
|
||||||
|
if (!e || !e.id) return null;
|
||||||
|
return `https://cdn.discordapp.com/emojis/${e.id}.${e.animated ? "gif" : "png"}?size=32`;
|
||||||
|
}
|
||||||
|
function assetUrl(appId, asset) {
|
||||||
|
if (!asset) return null;
|
||||||
|
if (String(asset).startsWith("mp:")) return "https://media.discordapp.net/" + asset.slice(3);
|
||||||
|
return `https://cdn.discordapp.com/app-assets/${appId}/${asset}.png`;
|
||||||
|
}
|
||||||
|
function esc(str) {
|
||||||
|
return String(str == null ? "" : str)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function intToHex(n) {
|
||||||
|
return "#" + (Number(n) >>> 0).toString(16).padStart(6, "0").slice(-6);
|
||||||
|
}
|
||||||
|
function guildBadgeUrl(pg) {
|
||||||
|
if (!pg || !pg.badge || !pg.identity_guild_id) return null;
|
||||||
|
return `https://cdn.discordapp.com/guild-tag-badges/${pg.identity_guild_id}/${pg.badge}.png?size=24`;
|
||||||
|
}
|
||||||
|
const PLATFORM_ICONS = {
|
||||||
|
desktop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="13" rx="1.5"/><path d="M8 21h8M12 17v4"/></svg>',
|
||||||
|
mobile: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2.5"/><path d="M11 18h2"/></svg>',
|
||||||
|
web: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18"/></svg>'
|
||||||
|
};
|
||||||
|
function platformIcons(d) {
|
||||||
|
let html = "";
|
||||||
|
if (d.active_on_discord_desktop) html += '<span class="pc-plat" title="Desktop">' + PLATFORM_ICONS.desktop + "</span>";
|
||||||
|
if (d.active_on_discord_mobile) html += '<span class="pc-plat" title="Mobile">' + PLATFORM_ICONS.mobile + "</span>";
|
||||||
|
if (d.active_on_discord_web || d.active_on_discord_embedded) html += '<span class="pc-plat" title="Web">' + PLATFORM_ICONS.web + "</span>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
const BADGE_FLAGS = [
|
||||||
|
[1 << 0, "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"],
|
||||||
|
[1 << 1, "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"],
|
||||||
|
[1 << 2, "HypeSquad Events", "bf01d1073931f921909045f3a39fd264"],
|
||||||
|
[1 << 3, "Bug Hunter", "2717692c7dca7289b35297368a940dd0"],
|
||||||
|
[1 << 6, "HypeSquad Bravery", "8a88d63823d8a71cd5e390baa45efa02"],
|
||||||
|
[1 << 7, "HypeSquad Brilliance", "011940fd013da3f7fb926e4a1cd2e618"],
|
||||||
|
[1 << 8, "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"],
|
||||||
|
[1 << 9, "Early Supporter", "7060786766c9c840eb3019e725d2b358"],
|
||||||
|
[1 << 14, "Bug Hunter Gold", "848f79194d4be5ff5f81505cbd0ce1e6"],
|
||||||
|
[1 << 17, "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"],
|
||||||
|
[1 << 18, "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"],
|
||||||
|
[1 << 22, "Active Developer", "6bdc42827a38498929a4920da12695d9"]
|
||||||
|
];
|
||||||
|
function renderBadges(flags) {
|
||||||
|
flags = Number(flags) || 0;
|
||||||
|
let html = "";
|
||||||
|
for (const [bit, name, hash] of BADGE_FLAGS) {
|
||||||
|
if (flags & bit) {
|
||||||
|
html += '<img class="pc-badge" src="https://cdn.discordapp.com/badge-icons/' + hash +
|
||||||
|
'.png" alt="' + esc(name) + '" title="' + esc(name) + '" onerror="this.remove()">';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Richer badges via dstn.to — Nitro, boosts, quests, orbs… everything
|
||||||
|
// Discord actually shows, which public_flags (0 for most) can't give.
|
||||||
|
let dstnBadges = null;
|
||||||
|
let lastFlags = 0;
|
||||||
|
function renderDstnBadges() {
|
||||||
|
return dstnBadges.map(function (b) {
|
||||||
|
const img = '<img class="pc-badge" src="https://cdn.discordapp.com/badge-icons/' + esc(b.icon) +
|
||||||
|
'.png" alt="' + esc(b.description || b.id) + '" title="' + esc(b.description || b.id) + '" onerror="this.remove()">';
|
||||||
|
return b.link
|
||||||
|
? '<a class="pc-badge-link" href="' + esc(b.link) + '" target="_blank" rel="noopener">' + img + "</a>"
|
||||||
|
: img;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
function paintBadges() {
|
||||||
|
if (!badgesEl) return;
|
||||||
|
badgesEl.innerHTML = (dstnBadges && dstnBadges.length) ? renderDstnBadges() : renderBadges(lastFlags);
|
||||||
|
}
|
||||||
|
function rgbTriplet(n) {
|
||||||
|
n = Number(n) >>> 0;
|
||||||
|
return ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255);
|
||||||
|
}
|
||||||
|
function applyProfileGradient(colors) {
|
||||||
|
if (!colors || colors.length < 2) return;
|
||||||
|
card.style.setProperty("--pc-grad-1-rgb", rgbTriplet(colors[0]));
|
||||||
|
card.style.setProperty("--pc-grad-2-rgb", rgbTriplet(colors[1]));
|
||||||
|
card.classList.add("has-profile-grad");
|
||||||
|
}
|
||||||
|
function loadDstn() {
|
||||||
|
fetch("https://dcdn.dstn.to/profile/" + DISCORD_USER_ID)
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j) return;
|
||||||
|
if (Array.isArray(j.badges)) { dstnBadges = j.badges; paintBadges(); }
|
||||||
|
if (j.user_profile && Array.isArray(j.user_profile.theme_colors)) {
|
||||||
|
applyProfileGradient(j.user_profile.theme_colors);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- album-art → Catppuccin accent --------------------------------------
|
||||||
|
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({ r, g, b }); }
|
||||||
|
}
|
||||||
|
return pal;
|
||||||
|
}
|
||||||
|
function nearestAccent(r, g, b) {
|
||||||
|
const pal = getThemePalette();
|
||||||
|
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 || 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}`;
|
||||||
|
card.style.setProperty("--np-accent", rgb);
|
||||||
|
card.classList.add("has-accent");
|
||||||
|
document.documentElement.style.setProperty("--accent-rgb", rgb);
|
||||||
|
} catch (e) { resetAccent(); }
|
||||||
|
};
|
||||||
|
img.onerror = resetAccent;
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
function resetAccent() {
|
||||||
|
lastArtUrl = null;
|
||||||
|
card.classList.remove("has-accent");
|
||||||
|
card.style.removeProperty("--np-accent");
|
||||||
|
document.documentElement.style.removeProperty("--accent-rgb");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- section (row) builders --------------------------------------------
|
||||||
|
function rowText(kind, title, sub, extra) {
|
||||||
|
return (
|
||||||
|
'<span class="pc-row-text">' +
|
||||||
|
'<span class="pc-row-kind">' + esc(kind) + "</span>" +
|
||||||
|
'<span class="pc-row-title">' + esc(title) + "</span>" +
|
||||||
|
'<span class="pc-row-sub">' + esc(sub) + "</span>" +
|
||||||
|
(extra || "") +
|
||||||
|
"</span>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function customRow(a) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "pc-row pc-custom";
|
||||||
|
const eu = emojiUrl(a.emoji);
|
||||||
|
row.innerHTML =
|
||||||
|
(eu ? '<img class="pc-emoji" src="' + eu + '" alt="">'
|
||||||
|
: '<span class="pc-row-ic pc-dot" aria-hidden="true"></span>') +
|
||||||
|
'<span class="pc-custom-text">' + esc(a.state || "") + "</span>";
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spotifyRow(s) {
|
||||||
|
const row = document.createElement("a");
|
||||||
|
row.className = "pc-row pc-spotify";
|
||||||
|
row.target = "_blank";
|
||||||
|
row.rel = "noopener";
|
||||||
|
row.href = s.track_id ? "https://open.spotify.com/track/" + s.track_id : "https://open.spotify.com/";
|
||||||
|
if (s.album) row.title = (s.song || "") + " — " + s.album;
|
||||||
|
if (s.timestamps && s.timestamps.start) row.dataset.start = s.timestamps.start;
|
||||||
|
if (s.timestamps && s.timestamps.end) row.dataset.end = s.timestamps.end;
|
||||||
|
row.innerHTML =
|
||||||
|
(s.album_art_url ? '<img class="pc-art" src="' + esc(s.album_art_url) + '" alt="">' : "") +
|
||||||
|
rowText("Listening to Spotify", s.song || "", s.artist || "",
|
||||||
|
'<span class="pc-progress" aria-hidden="true">' +
|
||||||
|
'<span class="pc-bar"><span class="pc-fill"></span></span>' +
|
||||||
|
'<span class="pc-times"><span class="pc-cur">0:00</span><span class="pc-dur">0:00</span></span>' +
|
||||||
|
"</span>");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic activity row (type 0). Discord presence exposes no link for
|
||||||
|
// games or apps, so this renders as a non-clickable card.
|
||||||
|
function activityRow(a) {
|
||||||
|
const isCode = /visual studio code|vscode/i.test(a.name || "");
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "pc-row pc-row--stack " + (isCode ? "pc-dev" : "pc-game");
|
||||||
|
if (a.timestamps && a.timestamps.start) row.dataset.elapsedStart = a.timestamps.start;
|
||||||
|
|
||||||
|
const large = a.assets && a.assets.large_image && assetUrl(a.application_id, a.assets.large_image);
|
||||||
|
const small = a.assets && a.assets.small_image && assetUrl(a.application_id, a.assets.small_image);
|
||||||
|
const iconHtml = large
|
||||||
|
? '<span class="pc-ic-wrap">' +
|
||||||
|
'<img class="pc-row-ic-img" src="' + esc(large) + '" alt="">' +
|
||||||
|
(small ? '<img class="pc-ic-badge" src="' + esc(small) + '" alt="" title="' + esc(a.assets.small_text || "") + '" onerror="this.remove()">' : "") +
|
||||||
|
"</span>"
|
||||||
|
: '<span class="pc-row-ic pc-dot" aria-hidden="true"></span>';
|
||||||
|
|
||||||
|
let kind = isCode ? "Coding" : "Playing " + (a.name || "");
|
||||||
|
if (a.party && a.party.size && a.party.size.length === 2 && a.party.size[1]) {
|
||||||
|
kind += " · " + a.party.size[0] + " of " + a.party.size[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = document.createElement("div");
|
||||||
|
main.className = "pc-row-link";
|
||||||
|
main.innerHTML = iconHtml +
|
||||||
|
rowText(kind, a.details || (isCode ? "" : a.name) || "",
|
||||||
|
a.state || (a.assets && a.assets.large_text) || "",
|
||||||
|
'<span class="pc-row-elapsed"></span>');
|
||||||
|
row.appendChild(main);
|
||||||
|
|
||||||
|
// Discord only exposes button *labels* (not URLs) via presence, so these
|
||||||
|
// are shown as plain (non-clickable) chips.
|
||||||
|
if (a.buttons && a.buttons.length) {
|
||||||
|
const bwrap = document.createElement("div");
|
||||||
|
bwrap.className = "pc-buttons";
|
||||||
|
a.buttons.forEach(function (label) {
|
||||||
|
const b = document.createElement("span");
|
||||||
|
b.className = "pc-btn";
|
||||||
|
b.textContent = typeof label === "string" ? label : (label && label.label) || "Open";
|
||||||
|
bwrap.appendChild(b);
|
||||||
|
});
|
||||||
|
row.appendChild(bwrap);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamRow(a) {
|
||||||
|
const hasUrl = !!a.url;
|
||||||
|
const row = document.createElement(hasUrl ? "a" : "div");
|
||||||
|
row.className = "pc-row pc-stream";
|
||||||
|
if (hasUrl) {
|
||||||
|
row.target = "_blank";
|
||||||
|
row.rel = "noopener";
|
||||||
|
row.href = a.url;
|
||||||
|
}
|
||||||
|
const platform = (a.url && /twitch/i.test(a.url)) ? "Twitch"
|
||||||
|
: (a.url && /youtube/i.test(a.url)) ? "YouTube" : "Live";
|
||||||
|
row.innerHTML =
|
||||||
|
'<span class="pc-row-ic pc-dot" aria-hidden="true"></span>' +
|
||||||
|
rowText("Streaming on " + platform, a.details || a.name || "", a.state || "");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- render -------------------------------------------------------------
|
||||||
|
function render(d) {
|
||||||
|
if (!d) return;
|
||||||
|
latest = d;
|
||||||
|
|
||||||
|
const u = d.discord_user || {};
|
||||||
|
const status = d.discord_status || "offline";
|
||||||
|
card.dataset.status = status;
|
||||||
|
|
||||||
|
avImg.src = avatarUrl(u);
|
||||||
|
const deco = u.avatar_decoration_data;
|
||||||
|
if (deco && deco.asset) {
|
||||||
|
avDeco.src = `https://cdn.discordapp.com/avatar-decoration-presets/${deco.asset}.png?size=160`;
|
||||||
|
avDeco.hidden = false;
|
||||||
|
} else {
|
||||||
|
avDeco.hidden = true;
|
||||||
|
}
|
||||||
|
nameEl.textContent = u.display_name || u.global_name || u.username || "Discord User";
|
||||||
|
userEl.textContent = u.username ? "@" + u.username : "";
|
||||||
|
|
||||||
|
const styles = u.display_name_styles;
|
||||||
|
if (styles && styles.colors && styles.colors.length) {
|
||||||
|
const cols = styles.colors.map(intToHex);
|
||||||
|
nameEl.style.backgroundImage = "linear-gradient(90deg, " + (cols.length === 1 ? cols[0] + "," + cols[0] : cols.join(", ")) + ")";
|
||||||
|
nameEl.classList.add("is-gradient");
|
||||||
|
} else {
|
||||||
|
nameEl.style.backgroundImage = "";
|
||||||
|
nameEl.classList.remove("is-gradient");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pg = u.primary_guild;
|
||||||
|
if (pg && pg.tag && pg.identity_enabled) {
|
||||||
|
const badge = guildBadgeUrl(pg);
|
||||||
|
tagEl.innerHTML = (badge ? '<img class="pc-tag-badge" src="' + badge + '" alt="" onerror="this.remove()">' : "") +
|
||||||
|
'<span class="pc-tag-text">' + esc(pg.tag) + "</span>";
|
||||||
|
tagEl.hidden = false;
|
||||||
|
} else {
|
||||||
|
tagEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
platformsEl.innerHTML = platformIcons(d);
|
||||||
|
|
||||||
|
lastFlags = u.public_flags || 0;
|
||||||
|
paintBadges();
|
||||||
|
|
||||||
|
const loc = d.kv && d.kv.location;
|
||||||
|
if (loc) {
|
||||||
|
metaEl.innerHTML = '<span class="pc-pin" aria-hidden="true">📍</span>' + esc(loc);
|
||||||
|
metaEl.hidden = false;
|
||||||
|
} else {
|
||||||
|
metaEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acts = d.activities || [];
|
||||||
|
|
||||||
|
sections.innerHTML = "";
|
||||||
|
|
||||||
|
const custom = acts.find((a) => a.type === 4);
|
||||||
|
if (custom && (custom.state || (custom.emoji && custom.emoji.id))) sections.appendChild(customRow(custom));
|
||||||
|
|
||||||
|
if (d.listening_to_spotify && d.spotify) {
|
||||||
|
sections.appendChild(spotifyRow(d.spotify));
|
||||||
|
applyAccent(d.spotify.album_art_url);
|
||||||
|
} else {
|
||||||
|
resetAccent();
|
||||||
|
}
|
||||||
|
|
||||||
|
acts.filter((a) => a.type === 0).forEach((a) => sections.appendChild(activityRow(a)));
|
||||||
|
acts.filter((a) => a.type === 1).forEach((a) => sections.appendChild(streamRow(a)));
|
||||||
|
|
||||||
|
card.classList.toggle("has-sections", sections.children.length > 0);
|
||||||
|
updateTimes();
|
||||||
|
if (sections.querySelector("[data-start], [data-elapsed-start]")) startTicker();
|
||||||
|
else stopTicker();
|
||||||
|
|
||||||
|
card.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- time tickers (progress bar + elapsed labels) -----------------------
|
||||||
|
function updateTimes() {
|
||||||
|
const sp = sections.querySelector(".pc-spotify[data-start][data-end]");
|
||||||
|
if (sp) {
|
||||||
|
const start = +sp.dataset.start, end = +sp.dataset.end;
|
||||||
|
if (end > start) {
|
||||||
|
const elapsed = clamp(Date.now() - start, 0, end - start);
|
||||||
|
const fill = sp.querySelector(".pc-fill");
|
||||||
|
const cur = sp.querySelector(".pc-cur");
|
||||||
|
const dur = sp.querySelector(".pc-dur");
|
||||||
|
if (fill) fill.style.width = clamp((elapsed / (end - start)) * 100, 0, 100) + "%";
|
||||||
|
if (cur) cur.textContent = fmt(elapsed);
|
||||||
|
if (dur) dur.textContent = fmt(end - start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sections.querySelectorAll("[data-elapsed-start]").forEach((row) => {
|
||||||
|
const lbl = row.querySelector(".pc-row-elapsed");
|
||||||
|
if (lbl) lbl.textContent = elapsedStr(+row.dataset.elapsedStart);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startTicker() { if (!ticker) ticker = setInterval(updateTimes, 1000); }
|
||||||
|
function stopTicker() { if (ticker) { clearInterval(ticker); ticker = null; } }
|
||||||
|
|
||||||
|
// ---- 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; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.op === 0) {
|
||||||
|
const d = msg.t === "INIT_STATE" ? (msg.d && msg.d[DISCORD_USER_ID]) || msg.d : msg.d;
|
||||||
|
render(d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => { reconnectDelay = 1000; });
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
|
||||||
|
stopTicker();
|
||||||
|
setTimeout(connect, reconnectDelay);
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => { try { ws.close(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
loadDstn();
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden && latest) updateTimes();
|
||||||
|
});
|
||||||
|
})();
|
||||||
20
index.html
20
index.html
|
|
@ -53,23 +53,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<a class="now-playing" id="now-playing" target="_blank" rel="noopener" hidden>
|
<div id="now-playing"></div>
|
||||||
<img class="np-art" alt="" referrerpolicy="no-referrer" crossorigin="anonymous">
|
|
||||||
<span class="np-bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
||||||
<span class="np-text">
|
|
||||||
<span class="np-head">
|
|
||||||
<span class="np-status" aria-hidden="true"></span>
|
|
||||||
<span class="np-status-label"></span>
|
|
||||||
<span class="np-label">Now playing</span>
|
|
||||||
</span>
|
|
||||||
<span class="np-track"></span>
|
|
||||||
<span class="np-artist"></span>
|
|
||||||
<span class="np-progress" aria-hidden="true">
|
|
||||||
<span class="np-bar"><span class="np-fill"></span></span>
|
|
||||||
<span class="np-times"><span class="np-cur">0:00</span><span class="np-dur">0:00</span></span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<main class="hub">
|
<main class="hub">
|
||||||
<header class="hub-header">
|
<header class="hub-header">
|
||||||
|
|
@ -216,7 +200,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="/js/now-playing.js"></script>
|
<script src="/api/now-playing.js"></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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
Loading…
Reference in New Issue