change scrobbling to last.fm

This commit is contained in:
zoop 2026-09-10 14:51:53 -04:00
parent 6d38676a1b
commit ae41a40866
No known key found for this signature in database
GPG Key ID: 15C0D336C9E08F3F
12 changed files with 574 additions and 405 deletions

View File

@ -6,12 +6,15 @@ ADMIN_PASSWORD=
# 32+ random bytes, e.g. `openssl rand -hex 32`
SESSION_SECRET=
# one-time: get a refresh token via the spotify authorization-code flow with
# scopes: user-read-currently-playing user-read-recently-played user-top-read
# without these the "now playing" / "on repeat" sections just stay hidden.
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=
# music widget — now playing + weekly scrobble stats from last.fm. reads a PUBLIC
# profile, so just a key (last.fm/api/account/create) + your username. no auth.
LASTFM_API_KEY=
LASTFM_USER=
# (legacy) spotify — no longer used; the music widget is on last.fm now.
# SPOTIFY_CLIENT_ID=
# SPOTIFY_CLIENT_SECRET=
# SPOTIFY_REFRESH_TOKEN=
# currently-reading widget — personal token from https://hardcover.app/account/api
HARDCOVER_TOKEN=

BIN
public/badges/brgr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -314,6 +314,10 @@ const badgeUrl = `https://${site.domain}/badges/zachy.gif`;
z-index: 3;
transform: translate(var(--x), calc(var(--y) - 5px)) rotate(0deg) scale(1.2);
}
.badge:hover ~ .badge,
.badge:focus-visible ~ .badge {
transform: translate(var(--x), calc(var(--y) - 7px)) rotate(var(--t));
}
.badge:hover::before,
.badge:focus-visible::before {
top: 3px;

View File

@ -22,7 +22,7 @@ import { Icon } from "astro-icon/components";
hidden
/>
<span class="music__eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<Icon class="music__logo" name="ph:spotify-logo" aria-hidden="true" />
<Icon class="music__logo" name="ph:music-notes" aria-hidden="true" />
<span class="music__main">
<span class="music__label" id="music-label">now playing</span>
<span class="music__sub" id="music-sub" aria-hidden="true"></span>
@ -43,37 +43,58 @@ import { Icon } from "astro-icon/components";
</div>
<div class="music-wall__body">
<div class="music-block" id="np-section" hidden>
<h2 class="label">now playing</h2>
<div class="np" id="np">
<div class="np__body">
<img class="np__art np__art--none" id="np-art" alt="" width="64" height="64" decoding="async" />
<div class="np__meta">
<div class="np__title" id="np-title">&mdash;</div>
<div class="np__artist" id="np-artist">&mdash;</div>
<div class="np__foot">
<span class="np__tx" aria-hidden="true">
<Icon name="ph:skip-back-fill" />
<Icon name="ph:play-fill" />
<Icon name="ph:skip-forward-fill" />
</span>
<span class="np__dur" id="np-dur"></span>
<span class="np__ago" id="np-ago"></span>
<a class="np__src" id="np-src" href="#" target="_blank" rel="noopener">
<a class="np" id="np-src" href="#" target="_blank" rel="noopener" hidden>
<img class="np__art np__art--none" id="np-art" alt="" width="60" height="60" decoding="async" />
<span class="np__meta">
<span class="np__title" id="np-title">&mdash;</span>
<span class="np__by" id="np-by">&mdash;</span>
<span class="np__foot">
<span class="np__src">
<Icon class="np__logo" name="ph:spotify-logo-fill" aria-hidden="true" /><span>spotify</span>
</span>
<span class="np__ago" id="np-ago"></span>
</span>
</span>
</a>
<div class="stat-bigs" id="lfm-bigs" hidden>
<div class="stat-big">
<span class="stat-big__n" id="lfm-week">&mdash;</span>
<span class="stat-big__k">this week</span>
</div>
<div class="stat-big">
<span class="stat-big__n" id="lfm-all">&mdash;</span>
<span class="stat-big__k">all&#8209;time</span>
</div>
<div class="np__prog" id="np-prog" hidden><div class="np__prog-fill" id="np-prog-fill"></div></div>
</div>
<div class="stat-big">
<span class="stat-big__n" id="lfm-mins">&mdash;</span>
<span class="stat-big__k">minutes</span>
</div>
</div>
<div class="music-block top" id="top-section" hidden>
<h2 class="label">top tracks <span class="label__sub">last 4 weeks</span></h2>
<div class="mosaic" id="mosaic"></div>
<div class="mosaic__caps" id="mosaic-caps"></div>
<p class="lfm-lib" id="lfm-lib" hidden></p>
<div class="stat-block" id="lfm-top-block" hidden>
<h2 class="label">on repeat &middot; this week</h2>
<ul class="stat-list" id="lfm-top"></ul>
</div>
<div class="stat-block" id="lfm-artists-block" hidden>
<h2 class="label">top artists &middot; this week</h2>
<ul class="stat-list" id="lfm-artists"></ul>
</div>
<div class="stat-block" id="lfm-recent-block" hidden>
<h2 class="label">recently played</h2>
<ul class="stat-list stat-list--plain" id="lfm-recent"></ul>
</div>
<p class="wall-foot">
scrobbling <a
href="https://www.last.fm/user/zoop-zach"
target="_blank"
rel="noopener">via last.fm &#8599;</a>
</p>
</div>
</dialog>
</div>
@ -304,31 +325,51 @@ import { Icon } from "astro-icon/components";
overflow-y: auto;
max-height: calc(min(82vh, 40rem) - 2.6rem);
}
.music-block + .music-block {
margin-top: 1.4rem;
.music-wall__body .stat-bigs {
margin-top: 1rem;
}
.music-block .label {
margin-bottom: 0.7rem;
.music-wall__body .np[hidden] + .stat-bigs {
margin-top: 0;
}
.lfm-lib {
margin: 0.7rem 0 0;
font-family: var(--mono);
font-size: 0.66rem;
letter-spacing: 0.02em;
color: var(--faint);
}
.stat-list--plain li::before {
display: none;
}
.stat-list--plain .stat-list__v {
color: var(--faint);
}
.np {
width: 100%;
border: 1px solid var(--faint);
background: #151412;
}
.np__body {
display: flex;
gap: 0.7rem;
padding: 0.65rem;
gap: 0.8rem;
align-items: center;
padding: 0.7rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #151412;
text-decoration: none;
transition:
border-color 0.18s ease,
background 0.18s ease;
}
.np:hover,
.np:focus-visible {
border-color: var(--faint);
background: #191714;
}
.np__art {
width: 64px;
height: 64px;
flex-shrink: 0;
width: 60px;
height: 60px;
flex: none;
object-fit: cover;
border: 1px solid var(--faint);
border-radius: 4px;
background: var(--faint);
border-radius: 5px;
background: var(--line);
}
.np__art--none {
background: linear-gradient(145deg, #2c2a26, #171614);
@ -338,21 +379,25 @@ import { Icon } from "astro-icon/components";
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.05rem;
gap: 0.15rem;
font-family: var(--mono);
}
.np__title {
color: #fff;
font-size: 0.8rem;
line-height: 1.35;
color: var(--ink);
font-size: 0.85rem;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.18s ease;
}
.np__artist {
.np:hover .np__title,
.np:focus-visible .np__title {
color: #fff;
}
.np__by {
font-size: 0.72rem;
color: var(--muted);
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -361,157 +406,41 @@ import { Icon } from "astro-icon/components";
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.3rem;
margin-top: 0.32rem;
font-size: 0.68rem;
}
.np__tx {
display: inline-flex;
align-items: center;
gap: 0.32rem;
color: var(--muted);
flex-shrink: 0;
}
.np__tx svg {
display: block;
}
.np__dur {
color: var(--muted);
white-space: nowrap;
}
.np__ago {
color: var(--faint);
white-space: nowrap;
}
.np__src {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.3rem;
gap: 0.35ch;
color: var(--muted);
text-decoration: none;
white-space: nowrap;
}
.np__src:hover {
.np:hover .np__src {
color: #fff;
}
.np__logo {
width: 0.95em;
height: 0.95em;
width: 1em;
height: 1em;
flex: none;
color: #1ed760;
flex-shrink: 0;
display: block;
}
.np__prog {
height: 2px;
background: #2b2825;
.np--live .np__logo {
animation: np-glow 1.8s ease-in-out infinite;
}
.np__prog-fill {
height: 2px;
width: 0%;
background: linear-gradient(90deg, rgb(255, 224, 138), rgb(255, 171, 94), rgb(255, 95, 109));
transition: width 1s linear;
@keyframes np-glow {
50% {
filter: drop-shadow(0 0 4px rgba(30, 215, 96, 0.8));
}
.mosaic {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
grid-auto-flow: dense;
gap: 3px;
aspect-ratio: 4 / 3;
width: 100%;
}
.mosaic__cell {
position: relative;
display: block;
overflow: hidden;
min-width: 0;
background: rgba(255, 255, 255, 0.04);
}
.mosaic__cell--xl {
grid-column: span 2;
grid-row: span 2;
}
.mosaic__cell img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(0.7) brightness(0.82);
transition:
filter 0.18s ease,
transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.mosaic__cell:hover img,
.mosaic__cell:focus-visible img {
filter: none;
transform: scale(1.08);
}
.mosaic__caps {
position: relative;
margin-top: 0.5rem;
min-height: 1.35em;
font-family: var(--mono);
font-size: 0.74rem;
}
.mosaic__cap {
position: absolute;
inset: 0;
display: flex;
align-items: baseline;
gap: 0.55rem;
opacity: 0;
transition: opacity 0.14s ease;
pointer-events: none;
white-space: normal;
}
.mosaic__rank {
.np__ago {
margin-left: auto;
flex: none;
color: var(--faint);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
white-space: nowrap;
}
.mosaic__title {
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.mosaic__artist {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.mosaic__cap:first-child {
opacity: 1;
}
@supports selector(:has(*)) {
.top:has(.mosaic__cell:hover) .mosaic__cap,
.top:has(.mosaic__cell:focus-visible) .mosaic__cap {
opacity: 0;
}
.top:has(.mosaic__cell:nth-child(1):hover) .mosaic__cap:nth-child(1),
.top:has(.mosaic__cell:nth-child(1):focus-visible) .mosaic__cap:nth-child(1),
.top:has(.mosaic__cell:nth-child(2):hover) .mosaic__cap:nth-child(2),
.top:has(.mosaic__cell:nth-child(2):focus-visible) .mosaic__cap:nth-child(2),
.top:has(.mosaic__cell:nth-child(3):hover) .mosaic__cap:nth-child(3),
.top:has(.mosaic__cell:nth-child(3):focus-visible) .mosaic__cap:nth-child(3),
.top:has(.mosaic__cell:nth-child(4):hover) .mosaic__cap:nth-child(4),
.top:has(.mosaic__cell:nth-child(4):focus-visible) .mosaic__cap:nth-child(4),
.top:has(.mosaic__cell:nth-child(5):hover) .mosaic__cap:nth-child(5),
.top:has(.mosaic__cell:nth-child(5):focus-visible) .mosaic__cap:nth-child(5),
.top:has(.mosaic__cell:nth-child(6):hover) .mosaic__cap:nth-child(6),
.top:has(.mosaic__cell:nth-child(6):focus-visible) .mosaic__cap:nth-child(6),
.top:has(.mosaic__cell:nth-child(7):hover) .mosaic__cap:nth-child(7),
.top:has(.mosaic__cell:nth-child(7):focus-visible) .mosaic__cap:nth-child(7),
.top:has(.mosaic__cell:nth-child(8):hover) .mosaic__cap:nth-child(8),
.top:has(.mosaic__cell:nth-child(8):focus-visible) .mosaic__cap:nth-child(8),
.top:has(.mosaic__cell:nth-child(9):hover) .mosaic__cap:nth-child(9),
.top:has(.mosaic__cell:nth-child(9):focus-visible) .mosaic__cap:nth-child(9) {
opacity: 1;
@media (prefers-reduced-motion: reduce) {
.np--live .np__logo {
animation: none;
}
}

View File

@ -54,7 +54,7 @@ import { Icon } from "astro-icon/components";
<ul class="stat-list" id="sb-refs"></ul>
</div>
<p class="stats-wall__foot">
<p class="wall-foot">
all&#8209;time &middot; via <a
href="https://umami.is"
target="_blank"
@ -174,97 +174,6 @@ import { Icon } from "astro-icon/components";
max-height: calc(min(82vh, 40rem) - 2.6rem);
}
.stat-bigs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat-big {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 0.6rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #151412;
text-align: center;
}
.stat-big__n {
font-family: var(--mono);
font-size: 1.1rem;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.stat-big__k {
font-family: var(--mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: var(--faint);
}
.stat-block {
margin-top: 1.4rem;
}
.stat-block .label {
margin-bottom: 0.6rem;
}
.stat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.stat-list li {
position: relative;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1ch;
padding: 0.34rem 0.5rem;
border-radius: 4px;
overflow: hidden;
font-family: var(--mono);
font-size: 0.74rem;
}
.stat-list li::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: var(--w, 0%);
background: rgba(255, 171, 94, 0.1);
border-right: 1px solid rgba(255, 171, 94, 0.25);
}
.stat-list__k {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink);
}
.stat-list__v {
position: relative;
flex: none;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.stats-wall__foot {
margin: 1.5rem 0 0;
font-family: var(--mono);
font-size: 0.66rem;
letter-spacing: 0.03em;
color: var(--faint);
}
.stats-wall__foot a {
color: var(--faint);
text-decoration: none;
}
.stats-wall__foot a:hover {
color: var(--accent);
}
</style>
<script>

View File

@ -95,7 +95,7 @@ import { Icon } from "astro-icon/components";
display: block;
box-sizing: border-box;
width: fit-content;
min-width: 18rem;
min-width: min(18rem, 100%);
max-width: 100%;
padding: 1rem 1.1rem 0.85rem;
border: 1px solid var(--line);

View File

@ -86,4 +86,5 @@ export const buttons = [
{ label: "increment", iframe: "//incr.easrng.net/badge?key=zachy.cc-2026", bg: "//incr.easrng.net/bg.gif", href: null },
{ label: "ro.er", img: "/badges/ro-er.gif", still: null, href: "https://retrobloxxer.github.io/" },
{ label: "kevadesu", img: "/badges/kevadesu.gif", still: null, href: "https://kevadesu.github.io/" },
{ label: "burger", img: "/badges/brgr.png", still: null, href: "https://burgeronthe.net/" },
];

View File

@ -41,7 +41,7 @@ const kaoCount = kaoMs > 0 ? `${Math.floor(kaoMs / 86_400_000)}d` : "in effect";
path === "/" && (
<>
<link rel="preconnect" href="https://assets.hardcover.app" crossorigin />
<link rel="preconnect" href="https://i.scdn.co" crossorigin />
<link rel="preconnect" href="https://lastfm-img.freetls.fastly.net" crossorigin />
</>
)
}

View File

@ -0,0 +1,149 @@
import type { APIRoute } from "astro";
import { env as cfEnv } from "cloudflare:workers";
export const prerender = false;
type Env = Record<string, string | undefined>;
function getEnv(): Env {
const nodeEnv = typeof process !== "undefined" ? process.env : {};
let workerEnv: Env = {};
try {
workerEnv = cfEnv as unknown as Env;
} catch {
}
return { ...nodeEnv, ...workerEnv };
}
const API = "https://ws.audioscrobbler.com/2.0/";
const NO_ART = "2a96cbd8b46e442fc41c2b86b821562f";
function pickArt(images: any): string | null {
if (!Array.isArray(images)) return null;
const order = ["extralarge", "large", "medium", "small"];
for (const size of order) {
const u = images.find((x: any) => x?.size === size)?.["#text"];
if (u && !u.includes(NO_ART)) return u;
}
return null;
}
const n = (v: any): number => {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
};
export const GET: APIRoute = async () => {
const e = getEnv();
const key = e.LASTFM_API_KEY;
const user = e.LASTFM_USER;
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
"cache-control": "public, max-age=180, s-maxage=300, stale-while-revalidate=1800",
},
});
const empty = {
configured: false,
nowPlaying: null as unknown,
top: [] as unknown[],
topArtists: [] as unknown[],
history: [] as unknown[],
weekPlays: 0,
allTimePlays: 0,
estMinutes: 0,
lib: { artists: 0, albums: 0, tracks: 0 },
};
if (!key || !user) return json(empty);
const call = (params: string) =>
fetch(`${API}?${params}&api_key=${key}&user=${encodeURIComponent(user)}&format=json`, {
headers: { "user-agent": "zachy.cc music widget" },
}).then((r) => r.json());
const from = Math.floor(Date.now() / 1000) - 7 * 86400;
try {
const [recent, top, artists, week, info] = await Promise.all([
call("method=user.getrecenttracks&limit=8&extended=1"),
call("method=user.gettoptracks&period=7day&limit=8"),
call("method=user.gettopartists&period=7day&limit=6"),
call(`method=user.getrecenttracks&limit=1&from=${from}`),
call("method=user.getinfo"),
]);
const rtRaw = (recent as any)?.recenttracks?.track;
const rtArr: any[] = Array.isArray(rtRaw) ? rtRaw : rtRaw ? [rtRaw] : [];
const rt = rtArr[0];
const nowPlaying = rt
? {
title: rt.name ?? "",
artist: rt.artist?.name ?? rt.artist?.["#text"] ?? "",
album: rt.album?.["#text"] ?? "",
art: pickArt(rt.image),
url: rt.url ?? "",
isPlaying: rt["@attr"]?.nowplaying === "true",
playedAt: rt.date?.uts ? n(rt.date.uts) * 1000 : null,
}
: null;
const history = rtArr
.filter((t: any) => t["@attr"]?.nowplaying !== "true" && t.date?.uts)
.slice(0, 6)
.map((t: any) => ({
title: t.name ?? "",
artist: t.artist?.name ?? t.artist?.["#text"] ?? "",
playedAt: n(t.date.uts) * 1000,
}))
.filter((t: any) => t.title);
const topTracks = ((top as any)?.toptracks?.track ?? [])
.map((t: any) => ({
title: t.name ?? "",
artist: t.artist?.name ?? "",
plays: n(t.playcount),
secs: n(t.duration),
url: t.url ?? "",
}))
.filter((t: any) => t.title);
const topArtists = ((artists as any)?.topartists?.artist ?? [])
.map((a: any) => ({
name: a.name ?? "",
plays: n(a.playcount),
url: a.url ?? "",
}))
.filter((a: any) => a.name);
const weekPlays = n((week as any)?.recenttracks?.["@attr"]?.total);
const u = (info as any)?.user ?? {};
const allTimePlays = n(u.playcount);
const lib = { artists: n(u.artist_count), albums: n(u.album_count), tracks: n(u.track_count) };
const durs = topTracks.map((t: any) => t.secs).filter((s: number) => s > 0);
const avgSec = durs.length ? durs.reduce((a: number, b: number) => a + b, 0) / durs.length : 210;
const estMinutes = Math.round((allTimePlays * avgSec) / 60);
if (!nowPlaying && !topTracks.length && !weekPlays) {
return json({ ...empty, configured: true });
}
return json({
configured: true,
nowPlaying,
top: topTracks,
topArtists,
history,
weekPlays,
allTimePlays,
estMinutes,
lib,
});
} catch {
return json({ ...empty, configured: true });
}
};

View File

@ -12,7 +12,7 @@ import { site, elsewhere } from "../data/site";
<Base>
<script is:inline>
window.__mm = {};
for (const k of ["spotify", "reading", "stats"]) {
for (const k of ["lastfm", "reading", "stats"]) {
window.__mm[k] = fetch("/api/" + k + ".json", { headers: { accept: "application/json" } })
.then(function (r) {
return r.json();

View File

@ -3,17 +3,47 @@ interface Track {
title: string;
artist: string;
url: string;
art: string | null;
plays?: number;
}
interface NP extends Track {
interface ArtistRow {
name: string;
plays: number;
url: string;
}
interface HistRow {
title: string;
artist: string;
playedAt: number;
}
interface NP {
title: string;
artist: string;
album: string;
art: string | null;
url: string;
isPlaying: boolean;
progressMs: number | null;
durationMs: number | null;
playedAt: string | null;
playedAt: number | null;
}
interface Data {
configured: boolean;
nowPlaying: NP | null;
top: Track[];
topArtists: ArtistRow[];
history: HistRow[];
weekPlays: number;
allTimePlays: number;
estMinutes: number;
lib: { artists: number; albums: number; tracks: number };
}
function ago(iso: string): string {
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
const $ = (id: string) => document.getElementById(id);
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const fmt = (n: number): string => new Intl.NumberFormat("en-US").format(n);
const clamp = (s: string, n = 22): string => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
function ago(ms: number): string {
const s = Math.max(0, (Date.now() - ms) / 1000);
if (s < 90) return "just now";
const m = s / 60;
if (m < 60) return `${Math.round(m)}m ago`;
@ -22,20 +52,9 @@ function ago(iso: string): string {
return `${Math.round(h / 24)}d ago`;
}
const mmss = (ms: number): string => {
const t = Math.round(ms / 1000);
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
};
const $ = (id: string) => document.getElementById(id);
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const clamp = (s: string, n = 22): string =>
s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s;
function renderNowPlaying(np: NP): void {
const section = $("np-section");
if (!section) return;
const card = $("np-src") as HTMLAnchorElement | null;
if (!card) return;
const art = $("np-art") as HTMLImageElement | null;
if (art) {
@ -47,99 +66,144 @@ function renderNowPlaying(np: NP): void {
art.classList.add("np__art--none");
}
}
const title = $("np-title");
const artist = $("np-artist");
if (title) title.textContent = np.title;
if (artist) artist.textContent = np.artist;
const src = $("np-src") as HTMLAnchorElement | null;
if (src && np.url) src.href = np.url;
const dur = $("np-dur");
const by = $("np-by");
const agoEl = $("np-ago");
const prog = $("np-prog");
const fill = $("np-prog-fill") as HTMLElement | null;
const total = np.durationMs;
if (title) title.textContent = np.title;
if (by) by.textContent = np.album ? `${np.artist} · ${np.album}` : np.artist;
if (agoEl) agoEl.textContent = np.isPlaying ? "" : np.playedAt ? ago(np.playedAt) : "";
if (np.isPlaying && np.progressMs != null && total) {
if (dur) dur.textContent = `${mmss(np.progressMs)} / ${mmss(total)}`;
if (agoEl) agoEl.textContent = "";
if (prog) prog.hidden = false;
const paint = (p: number): void => {
if (fill) fill.style.width = Math.min(100, (p / total) * 100) + "%";
};
paint(np.progressMs);
let p = np.progressMs;
const iv = window.setInterval(() => {
p += 1000;
if (p >= total) {
window.clearInterval(iv);
return;
}
paint(p);
}, 1000);
} else {
if (prog) prog.hidden = true;
if (dur) dur.textContent = total ? mmss(total) : "";
if (agoEl) agoEl.textContent = np.playedAt ? ago(np.playedAt) : "";
}
card.classList.toggle("np--live", np.isPlaying);
card.href = `https://open.spotify.com/search/${encodeURIComponent(`${np.title} ${np.artist}`)}`;
section.hidden = false;
card.hidden = false;
}
function renderTop(tracks: Track[]): void {
const section = $("top-section");
const mosaic = $("mosaic");
const caps = $("mosaic-caps");
if (!section || !mosaic || !caps || !tracks.length) return;
mosaic.innerHTML = tracks
.map((t, i) => {
const cls = i === 0 ? "mosaic__cell mosaic__cell--xl" : "mosaic__cell";
const label = esc(`${t.title}${t.artist}`);
const img = t.art
? `<img src="${esc(t.art)}" alt="${label}" loading="lazy" decoding="async">`
: "";
return `<a class="${cls}" href="${esc(t.url)}" target="_blank" rel="noopener" title="${label}">${img}</a>`;
})
.join("");
caps.innerHTML = tracks
.map((t, i) => {
const rank = String(i + 1).padStart(2, "0");
return `<div class="mosaic__cap"><span class="mosaic__rank">${rank}</span><span class="mosaic__title">${esc(
t.title,
)}</span><span class="mosaic__artist">${esc(t.artist)}</span></div>`;
})
.join("");
section.hidden = false;
interface Row {
k: string;
sub?: string;
v: string;
w?: number;
}
function mockData() {
const art = (n: number) => `https://picsum.photos/seed/np${n}/200`;
const mk = (n: number): Track => ({
title: ["After Party", "Gemstone", "Heartless", "Breathe", "ONE CALL", "Alright", "Nights", "Ivy", "Self Control"][n],
artist: ["Don Toliver", "Don Toliver", "Kanye West", "Yeat", "Rich Amiri", "Kendrick Lamar", "Frank Ocean", "Frank Ocean", "Frank Ocean"][n],
url: "https://open.spotify.com",
art: art(n),
function fillList(listId: string, blockId: string, rows: Row[]): void {
const list = $(listId);
const block = $(blockId);
if (!list || !block || !rows.length) return;
list.innerHTML = rows
.map((r) => {
const sub = r.sub ? ` <span class="stat-list__sub">${esc(r.sub)}</span>` : "";
return `<li style="--w:${r.w ?? 0}%"><span class="stat-list__k">${esc(
r.k,
)}${sub}</span><span class="stat-list__v">${r.v}</span></li>`;
})
.join("");
block.hidden = false;
}
function renderStats(d: Data): void {
const week = $("lfm-week");
const all = $("lfm-all");
const mins = $("lfm-mins");
const bigs = $("lfm-bigs");
if (week) week.textContent = fmt(d.weekPlays);
if (all) all.textContent = fmt(d.allTimePlays);
if (mins) mins.textContent = fmt(d.estMinutes);
if (bigs && (d.weekPlays || d.allTimePlays)) bigs.hidden = false;
const lib = $("lfm-lib");
if (lib && d.lib && (d.lib.artists || d.lib.tracks)) {
lib.textContent = `${fmt(d.lib.artists)} artists · ${fmt(d.lib.albums)} albums · ${fmt(
d.lib.tracks,
)} tracks in the library`;
lib.hidden = false;
}
const trackMax = d.top.reduce((m, t) => Math.max(m, t.plays ?? 0), 0) || 1;
fillList(
"lfm-top",
"lfm-top-block",
d.top.map((t) => ({
k: t.title,
sub: t.artist,
v: `${fmt(t.plays ?? 0)}&times;`,
w: Math.round(((t.plays ?? 0) / trackMax) * 100),
})),
);
const artMax = d.topArtists.reduce((m, a) => Math.max(m, a.plays), 0) || 1;
fillList(
"lfm-artists",
"lfm-artists-block",
d.topArtists.map((a) => ({
k: a.name,
v: `${fmt(a.plays)}&times;`,
w: Math.round((a.plays / artMax) * 100),
})),
);
fillList(
"lfm-recent",
"lfm-recent-block",
d.history.map((h) => ({ k: h.title, sub: h.artist, v: ago(h.playedAt) })),
);
}
function mockData(): Data {
const mk = (title: string, artist: string, plays: number): Track => ({
title,
artist,
url: "https://www.last.fm",
plays,
});
return {
configured: true,
nowPlaying: { ...mk(0), isPlaying: true, progressMs: 78000, durationMs: 175000, playedAt: null },
top: Array.from({ length: 9 }, (_, i) => mk(i)),
nowPlaying: {
title: "20 Min",
artist: "Lil Uzi Vert",
album: "Luv Is Rage 2 (Deluxe)",
art: "https://picsum.photos/seed/np/200",
url: "https://www.last.fm",
isPlaying: true,
playedAt: null,
},
top: [
mk("After Party", "Don Toliver", 14),
mk("NIGHTS LIKE THIS", "Kehlani", 11),
mk("20 Min", "Lil Uzi Vert", 9),
mk("Gemstones", "Don Toliver", 6),
mk("HEARTLESS", "The Weeknd", 4),
],
topArtists: [
{ name: "Don Toliver", plays: 26, url: "" },
{ name: "Lil Uzi Vert", plays: 18, url: "" },
{ name: "Kehlani", plays: 12, url: "" },
{ name: "The Weeknd", plays: 7, url: "" },
],
history: [
{ title: "20 Min", artist: "Lil Uzi Vert", playedAt: Date.now() - 6 * 60_000 },
{ title: "After Party", artist: "Don Toliver", playedAt: Date.now() - 12 * 60_000 },
{ title: "Gemstones", artist: "Don Toliver", playedAt: Date.now() - 40 * 60_000 },
{ title: "NIGHTS LIKE THIS", artist: "Kehlani", playedAt: Date.now() - 3 * 3_600_000 },
{ title: "HEARTLESS", artist: "The Weeknd", playedAt: Date.now() - 26 * 3_600_000 },
],
weekPlays: 128,
allTimePlays: 4210,
estMinutes: 13892,
lib: { artists: 214, albums: 388, tracks: 1102 },
};
}
type MusicState = "ready" | "idle" | "off";
async function load(): Promise<MusicState> {
let data: any;
let data: Data;
try {
const mm = (window as any).__mm?.spotify as Promise<any> | undefined;
const mm = (window as any).__mm?.lastfm as Promise<Data> | undefined;
data = mm
? await mm
: await fetch("/api/spotify.json", { headers: { accept: "application/json" } }).then((r) =>
: await fetch("/api/lastfm.json", { headers: { accept: "application/json" } }).then((r) =>
r.json(),
);
} catch {
@ -147,43 +211,42 @@ async function load(): Promise<MusicState> {
}
if (location.search.includes("mockmusic")) data = mockData();
if (!data || !data.configured) return "off";
if (data.nowPlaying) renderNowPlaying(data.nowPlaying as NP);
if (Array.isArray(data.top)) renderTop(data.top as Track[]);
const np = data.nowPlaying;
const top = Array.isArray(data.top) ? data.top : [];
data.top = top;
data.topArtists = Array.isArray(data.topArtists) ? data.topArtists : [];
data.history = Array.isArray(data.history) ? data.history : [];
data.lib = data.lib ?? { artists: 0, albums: 0, tracks: 0 };
if (np) renderNowPlaying(np);
renderStats(data);
const trigger = document.querySelector<HTMLButtonElement>(".music__trigger");
if (trigger && (data.nowPlaying || (Array.isArray(data.top) && data.top.length))) {
const hasAnything = !!np || top.length > 0 || !!data.weekPlays;
if (trigger && hasAnything) {
trigger.hidden = false;
const label = $("music-label");
const sub = $("music-sub");
const artEl = $("music-art") as HTMLImageElement | null;
const np = data.nowPlaying as NP | null;
const cover = np?.art || (data.top as Track[] | undefined)?.[0]?.art || null;
if (artEl && cover) {
artEl.src = cover;
if (artEl && np?.art) {
artEl.src = np.art;
artEl.hidden = false;
}
const sub = $("music-sub");
const bar = $("music-bar");
const fill = bar?.firstElementChild as HTMLElement | null;
if (label) {
if (np?.isPlaying) {
trigger.classList.add("music__trigger--live");
label.textContent = clamp(np.title);
trigger.title = `${np.title}${np.artist}`;
if (sub) sub.textContent = np.artist;
if (bar && fill && np.progressMs != null && np.durationMs) {
fill.style.setProperty("--p", `${Math.min(100, (np.progressMs / np.durationMs) * 100)}%`);
bar.hidden = false;
}
} else if (np?.playedAt) {
} else if (np) {
label.textContent = "last played";
trigger.title = `${np.title}${np.artist}`;
if (sub) sub.textContent = `${clamp(np.title, 26)} · ${np.artist}`;
} else {
label.textContent = "top tracks";
if (sub) sub.textContent = "last 4 weeks";
label.textContent = "music";
if (sub) sub.textContent = `${fmt(data.weekPlays)} plays this week`;
}
}
return "ready";
@ -200,7 +263,7 @@ const apply = (state: MusicState) => {
trigger.hidden = false;
trigger.disabled = true;
const label = document.getElementById("music-label");
if (label) label.textContent = "not playing";
if (label) label.textContent = "not scrobbling";
}
}
};

View File

@ -60,7 +60,7 @@ body {
text-rendering: optimizeLegibility;
max-width: var(--col);
margin-inline: auto;
padding: clamp(2.5rem, 9vw, 4.5rem) clamp(1.5rem, 6vw, 2rem) clamp(5rem, 12vw, 7rem);
padding: clamp(2.5rem, 9vw, 4.5rem) clamp(1.25rem, 5vw, 2rem) clamp(5rem, 12vw, 7rem);
}
::selection {
@ -204,9 +204,10 @@ img {
.topbar {
margin: 0 0 clamp(2rem, 5vw, 3rem);
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
gap: 0.6rem 1rem;
}
.mark {
font-family: var(--display);
@ -550,6 +551,116 @@ img {
color: var(--accent);
}
.stat-bigs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat-big {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 0.6rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #151412;
text-align: center;
}
.stat-big__n {
font-family: var(--mono);
font-size: 1.1rem;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.stat-big__k {
font-family: var(--mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: var(--faint);
}
.stat-block {
margin-top: 1.4rem;
}
.stat-block .label {
margin-bottom: 0.6rem;
}
.stat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.stat-list li {
position: relative;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1ch;
padding: 0.34rem 0.5rem;
border-radius: 4px;
overflow: hidden;
font-family: var(--mono);
font-size: 0.74rem;
}
.stat-list li::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: var(--w, 0%);
background: rgba(255, 171, 94, 0.1);
border-right: 1px solid rgba(255, 171, 94, 0.25);
}
.stat-list__k {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink);
}
.stat-list__k .stat-list__sub {
color: var(--faint);
}
.stat-list__v {
position: relative;
flex: none;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.wall-foot {
margin: 1.5rem 0 0;
font-family: var(--mono);
font-size: 0.66rem;
letter-spacing: 0.03em;
color: var(--faint);
}
.wall-foot a {
color: var(--faint);
text-decoration: none;
}
.wall-foot a:hover {
color: var(--accent);
}
@media (max-width: 25rem) {
.ribbon__sub {
display: none;
}
.ribbon__link {
padding: 0.5rem 0.35rem 0.5rem 0.6rem;
}
.ribbon__x {
padding: 0 0.55rem;
}
}
@media (max-width: 20rem) {
.mm-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;