diff --git a/.gitignore b/.gitignore index eb8d4a0..88be05f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ pnpm-debug.log* # wrangler local state (miniflare KV/D1/etc.) .wrangler/ + +scripts/spotify-auth.mjs diff --git a/scripts/spotify-auth.mjs b/scripts/spotify-auth.mjs new file mode 100644 index 0000000..6660c16 --- /dev/null +++ b/scripts/spotify-auth.mjs @@ -0,0 +1,92 @@ + + + + + + + + + +import { createServer } from "node:http"; +import { appendFileSync, readFileSync, writeFileSync, existsSync } from "node:fs"; + +const [clientId, clientSecret] = process.argv.slice(2); +if (!clientId || !clientSecret) { + console.error("usage: node scripts/spotify-auth.mjs "); + process.exit(1); +} + +const REDIRECT = "http://127.0.0.1:8888/callback"; +const SCOPE = "user-read-currently-playing user-read-recently-played user-top-read"; + +const authUrl = + "https://accounts.spotify.com/authorize?" + + new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: REDIRECT, + scope: SCOPE, + }); + +console.log("\n1. open this in your browser and approve:\n\n" + authUrl + "\n"); +console.log("2. waiting for the redirect on " + REDIRECT + " ...\n"); + +const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://127.0.0.1:8888"); + if (url.pathname !== "/callback") { + res.writeHead(404).end(); + return; + } + const code = url.searchParams.get("code"); + const err = url.searchParams.get("error"); + if (err || !code) { + res.writeHead(400).end("no code (" + (err || "missing") + ")"); + server.close(); + process.exit(1); + } + + const tokenRes = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64"), + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + }), + }); + const data = await tokenRes.json(); + + if (!data.refresh_token) { + res.writeHead(500).end("token exchange failed: " + JSON.stringify(data)); + console.error("\ntoken exchange failed:\n", data); + server.close(); + process.exit(1); + } + + const block = + `\nSPOTIFY_CLIENT_ID=${clientId}\n` + + `SPOTIFY_CLIENT_SECRET=${clientSecret}\n` + + `SPOTIFY_REFRESH_TOKEN=${data.refresh_token}\n`; + + + if (existsSync(".dev.vars")) { + const cur = readFileSync(".dev.vars", "utf8").replace(/^SPOTIFY_[A-Z_]+=.*$/gm, "").replace(/\n{3,}/g, "\n\n"); + writeFileSync(".dev.vars", cur.trimEnd() + "\n" + block); + } else { + appendFileSync(".dev.vars", block); + } + + res.writeHead(200, { "Content-Type": "text/plain" }).end("done — you can close this tab."); + console.log("wrote SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET / SPOTIFY_REFRESH_TOKEN to .dev.vars\n"); + console.log("for prod, run:"); + console.log(" npx wrangler secret put SPOTIFY_CLIENT_ID"); + console.log(" npx wrangler secret put SPOTIFY_CLIENT_SECRET"); + console.log(" npx wrangler secret put SPOTIFY_REFRESH_TOKEN\n"); + server.close(); + process.exit(0); +}); + +server.listen(8888, "127.0.0.1"); diff --git a/src/components/Music.astro b/src/components/Music.astro index c766ed3..b5d7709 100644 --- a/src/components/Music.astro +++ b/src/components/Music.astro @@ -13,7 +13,16 @@ data-umami-event="music-open" hidden > - + + now playing @@ -84,39 +93,72 @@ .music__trigger { display: inline-flex; align-items: center; - gap: 0.55rem; - padding: 0.35rem 0; - background: none; - border: 0; + gap: 0.6rem; + padding: 0.3rem 0.6rem 0.3rem 0.35rem; + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 7px; cursor: pointer; font-family: var(--mono); - font-size: 0.78rem; + font-size: 0.76rem; letter-spacing: 0.02em; color: var(--muted); - transition: color 0.18s ease; + transition: + border-color 0.18s ease, + color 0.18s ease; } .music__trigger:hover { - color: var(--accent-2); + border-color: var(--faint); + color: var(--ink); } .music__trigger:focus-visible { outline: 2px solid var(--accent); - outline-offset: 4px; + outline-offset: 3px; + } + .music__art { + width: 30px; + height: 30px; + flex: none; + object-fit: cover; + border: 1px solid var(--line); border-radius: 3px; } - .music__note { - color: var(--accent); - font-size: 0.9rem; + .music__eq { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 15px; + flex: none; + padding: 0 0.1rem; } - .music__trigger--live .music__note { - animation: music-pulse 1.6s ease-in-out infinite; + .music__eq i { + width: 2.5px; + height: 28%; + background: var(--accent); + border-radius: 1px; } - @keyframes music-pulse { - 50% { - opacity: 0.35; + .music__trigger--live .music__eq i { + animation: music-eq 0.95s ease-in-out infinite alternate; + } + .music__eq i:nth-child(2) { + animation-delay: -0.4s; + } + .music__eq i:nth-child(3) { + animation-delay: -0.68s; + } + .music__eq i:nth-child(4) { + animation-delay: -0.2s; + } + @keyframes music-eq { + from { + height: 18%; + } + to { + height: 100%; } } .music__label { - max-width: 22ch; + max-width: 26ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -399,8 +441,9 @@ } @media (prefers-reduced-motion: reduce) { - .music__trigger--live .music__note { + .music__trigger--live .music__eq i { animation: none; + height: 55%; } } diff --git a/src/scripts/music.ts b/src/scripts/music.ts index 649c2f1..5b30594 100644 --- a/src/scripts/music.ts +++ b/src/scripts/music.ts @@ -148,13 +148,22 @@ async function load(): Promise { if (trigger && (data.nowPlaying || (Array.isArray(data.top) && data.top.length))) { trigger.hidden = false; const label = $("music-label"); + const artEl = $("music-art") as HTMLImageElement | null; const np = data.nowPlaying as NP | null; - if (label && np) { - if (np.isPlaying) { + const cover = np?.art || (data.top as Track[] | undefined)?.[0]?.art || null; + + if (artEl && cover) { + artEl.src = cover; + artEl.hidden = false; + } + if (label) { + if (np?.isPlaying) { trigger.classList.add("music__trigger--live"); - label.textContent = np.title; - } else if (np.playedAt) { - label.textContent = "last played"; + label.textContent = `${np.title} · ${np.artist}`; + } else if (np?.playedAt) { + label.textContent = `last played · ${np.title}`; + } else { + label.textContent = "top tracks"; } } }