change scrobbling to last.fm
This commit is contained in:
parent
6d38676a1b
commit
ae41a40866
@ -6,12 +6,15 @@ ADMIN_PASSWORD=
|
|||||||
# 32+ random bytes, e.g. `openssl rand -hex 32`
|
# 32+ random bytes, e.g. `openssl rand -hex 32`
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
# one-time: get a refresh token via the spotify authorization-code flow with
|
# music widget — now playing + weekly scrobble stats from last.fm. reads a PUBLIC
|
||||||
# scopes: user-read-currently-playing user-read-recently-played user-top-read
|
# profile, so just a key (last.fm/api/account/create) + your username. no auth.
|
||||||
# without these the "now playing" / "on repeat" sections just stay hidden.
|
LASTFM_API_KEY=
|
||||||
SPOTIFY_CLIENT_ID=
|
LASTFM_USER=
|
||||||
SPOTIFY_CLIENT_SECRET=
|
|
||||||
SPOTIFY_REFRESH_TOKEN=
|
# (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
|
# currently-reading widget — personal token from https://hardcover.app/account/api
|
||||||
HARDCOVER_TOKEN=
|
HARDCOVER_TOKEN=
|
||||||
|
|||||||
BIN
public/badges/brgr.png
Normal file
BIN
public/badges/brgr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@ -314,6 +314,10 @@ const badgeUrl = `https://${site.domain}/badges/zachy.gif`;
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
transform: translate(var(--x), calc(var(--y) - 5px)) rotate(0deg) scale(1.2);
|
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:hover::before,
|
||||||
.badge:focus-visible::before {
|
.badge:focus-visible::before {
|
||||||
top: 3px;
|
top: 3px;
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { Icon } from "astro-icon/components";
|
|||||||
hidden
|
hidden
|
||||||
/>
|
/>
|
||||||
<span class="music__eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
<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__main">
|
||||||
<span class="music__label" id="music-label">now playing</span>
|
<span class="music__label" id="music-label">now playing</span>
|
||||||
<span class="music__sub" id="music-sub" aria-hidden="true"></span>
|
<span class="music__sub" id="music-sub" aria-hidden="true"></span>
|
||||||
@ -43,37 +43,58 @@ import { Icon } from "astro-icon/components";
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="music-wall__body">
|
<div class="music-wall__body">
|
||||||
<div class="music-block" id="np-section" hidden>
|
<a class="np" id="np-src" href="#" target="_blank" rel="noopener" hidden>
|
||||||
<h2 class="label">now playing</h2>
|
<img class="np__art np__art--none" id="np-art" alt="" width="60" height="60" decoding="async" />
|
||||||
<div class="np" id="np">
|
<span class="np__meta">
|
||||||
<div class="np__body">
|
<span class="np__title" id="np-title">—</span>
|
||||||
<img class="np__art np__art--none" id="np-art" alt="" width="64" height="64" decoding="async" />
|
<span class="np__by" id="np-by">—</span>
|
||||||
<div class="np__meta">
|
<span class="np__foot">
|
||||||
<div class="np__title" id="np-title">—</div>
|
<span class="np__src">
|
||||||
<div class="np__artist" id="np-artist">—</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">
|
|
||||||
<Icon class="np__logo" name="ph:spotify-logo-fill" aria-hidden="true" /><span>spotify</span>
|
<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>
|
</a>
|
||||||
|
|
||||||
|
<div class="stat-bigs" id="lfm-bigs" hidden>
|
||||||
|
<div class="stat-big">
|
||||||
|
<span class="stat-big__n" id="lfm-week">—</span>
|
||||||
|
<span class="stat-big__k">this week</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-big">
|
||||||
|
<span class="stat-big__n" id="lfm-all">—</span>
|
||||||
|
<span class="stat-big__k">all‑time</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="np__prog" id="np-prog" hidden><div class="np__prog-fill" id="np-prog-fill"></div></div>
|
<div class="stat-big">
|
||||||
</div>
|
<span class="stat-big__n" id="lfm-mins">—</span>
|
||||||
|
<span class="stat-big__k">minutes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="music-block top" id="top-section" hidden>
|
<p class="lfm-lib" id="lfm-lib" hidden></p>
|
||||||
<h2 class="label">top tracks <span class="label__sub">last 4 weeks</span></h2>
|
|
||||||
<div class="mosaic" id="mosaic"></div>
|
<div class="stat-block" id="lfm-top-block" hidden>
|
||||||
<div class="mosaic__caps" id="mosaic-caps"></div>
|
<h2 class="label">on repeat · this week</h2>
|
||||||
|
<ul class="stat-list" id="lfm-top"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-block" id="lfm-artists-block" hidden>
|
||||||
|
<h2 class="label">top artists · 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 ↗</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
@ -304,31 +325,51 @@ import { Icon } from "astro-icon/components";
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: calc(min(82vh, 40rem) - 2.6rem);
|
max-height: calc(min(82vh, 40rem) - 2.6rem);
|
||||||
}
|
}
|
||||||
.music-block + .music-block {
|
.music-wall__body .stat-bigs {
|
||||||
margin-top: 1.4rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.music-block .label {
|
.music-wall__body .np[hidden] + .stat-bigs {
|
||||||
margin-bottom: 0.7rem;
|
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 {
|
.np {
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--faint);
|
|
||||||
background: #151412;
|
|
||||||
}
|
|
||||||
.np__body {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.7rem;
|
gap: 0.8rem;
|
||||||
padding: 0.65rem;
|
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 {
|
.np__art {
|
||||||
width: 64px;
|
width: 60px;
|
||||||
height: 64px;
|
height: 60px;
|
||||||
flex-shrink: 0;
|
flex: none;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid var(--faint);
|
border-radius: 5px;
|
||||||
border-radius: 4px;
|
background: var(--line);
|
||||||
background: var(--faint);
|
|
||||||
}
|
}
|
||||||
.np__art--none {
|
.np__art--none {
|
||||||
background: linear-gradient(145deg, #2c2a26, #171614);
|
background: linear-gradient(145deg, #2c2a26, #171614);
|
||||||
@ -338,21 +379,25 @@ import { Icon } from "astro-icon/components";
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: 0.15rem;
|
||||||
gap: 0.05rem;
|
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
.np__title {
|
.np__title {
|
||||||
color: #fff;
|
color: var(--ink);
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.35;
|
line-height: 1.3;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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);
|
color: var(--muted);
|
||||||
font-size: 0.74rem;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -361,157 +406,41 @@ import { Icon } from "astro-icon/components";
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.32rem;
|
||||||
font-size: 0.68rem;
|
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 {
|
.np__src {
|
||||||
margin-left: auto;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.35ch;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.np__src:hover {
|
.np:hover .np__src {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.np__logo {
|
.np__logo {
|
||||||
width: 0.95em;
|
width: 1em;
|
||||||
height: 0.95em;
|
height: 1em;
|
||||||
|
flex: none;
|
||||||
color: #1ed760;
|
color: #1ed760;
|
||||||
flex-shrink: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
.np__prog {
|
.np--live .np__logo {
|
||||||
height: 2px;
|
animation: np-glow 1.8s ease-in-out infinite;
|
||||||
background: #2b2825;
|
|
||||||
}
|
}
|
||||||
.np__prog-fill {
|
@keyframes np-glow {
|
||||||
height: 2px;
|
50% {
|
||||||
width: 0%;
|
filter: drop-shadow(0 0 4px rgba(30, 215, 96, 0.8));
|
||||||
background: linear-gradient(90deg, rgb(255, 224, 138), rgb(255, 171, 94), rgb(255, 95, 109));
|
|
||||||
transition: width 1s linear;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.np__ago {
|
||||||
position: relative;
|
margin-left: auto;
|
||||||
display: block;
|
flex: none;
|
||||||
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 {
|
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
font-variant-numeric: tabular-nums;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.mosaic__title {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
color: var(--ink);
|
.np--live .np__logo {
|
||||||
overflow: hidden;
|
animation: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import { Icon } from "astro-icon/components";
|
|||||||
<ul class="stat-list" id="sb-refs"></ul>
|
<ul class="stat-list" id="sb-refs"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="stats-wall__foot">
|
<p class="wall-foot">
|
||||||
all‑time · via <a
|
all‑time · via <a
|
||||||
href="https://umami.is"
|
href="https://umami.is"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -174,97 +174,6 @@ import { Icon } from "astro-icon/components";
|
|||||||
max-height: calc(min(82vh, 40rem) - 2.6rem);
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -95,7 +95,7 @@ import { Icon } from "astro-icon/components";
|
|||||||
display: block;
|
display: block;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 18rem;
|
min-width: min(18rem, 100%);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 1rem 1.1rem 0.85rem;
|
padding: 1rem 1.1rem 0.85rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
@ -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: "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: "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: "kevadesu", img: "/badges/kevadesu.gif", still: null, href: "https://kevadesu.github.io/" },
|
||||||
|
{ label: "burger", img: "/badges/brgr.png", still: null, href: "https://burgeronthe.net/" },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -41,7 +41,7 @@ const kaoCount = kaoMs > 0 ? `${Math.floor(kaoMs / 86_400_000)}d` : "in effect";
|
|||||||
path === "/" && (
|
path === "/" && (
|
||||||
<>
|
<>
|
||||||
<link rel="preconnect" href="https://assets.hardcover.app" crossorigin />
|
<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 />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
149
src/pages/api/lastfm.json.ts
Normal file
149
src/pages/api/lastfm.json.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -12,7 +12,7 @@ import { site, elsewhere } from "../data/site";
|
|||||||
<Base>
|
<Base>
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
window.__mm = {};
|
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" } })
|
window.__mm[k] = fetch("/api/" + k + ".json", { headers: { accept: "application/json" } })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
return r.json();
|
return r.json();
|
||||||
|
|||||||
@ -3,17 +3,47 @@ interface Track {
|
|||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
url: 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;
|
isPlaying: boolean;
|
||||||
progressMs: number | null;
|
playedAt: number | null;
|
||||||
durationMs: number | null;
|
}
|
||||||
playedAt: string | 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 $ = (id: string) => document.getElementById(id);
|
||||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
const esc = (s: string): string =>
|
||||||
|
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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";
|
if (s < 90) return "just now";
|
||||||
const m = s / 60;
|
const m = s / 60;
|
||||||
if (m < 60) return `${Math.round(m)}m ago`;
|
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`;
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
||||||
const clamp = (s: string, n = 22): string =>
|
|
||||||
s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s;
|
|
||||||
|
|
||||||
function renderNowPlaying(np: NP): void {
|
function renderNowPlaying(np: NP): void {
|
||||||
const section = $("np-section");
|
const card = $("np-src") as HTMLAnchorElement | null;
|
||||||
if (!section) return;
|
if (!card) return;
|
||||||
|
|
||||||
const art = $("np-art") as HTMLImageElement | null;
|
const art = $("np-art") as HTMLImageElement | null;
|
||||||
if (art) {
|
if (art) {
|
||||||
@ -47,99 +66,144 @@ function renderNowPlaying(np: NP): void {
|
|||||||
art.classList.add("np__art--none");
|
art.classList.add("np__art--none");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = $("np-title");
|
const title = $("np-title");
|
||||||
const artist = $("np-artist");
|
const by = $("np-by");
|
||||||
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 agoEl = $("np-ago");
|
const agoEl = $("np-ago");
|
||||||
const prog = $("np-prog");
|
if (title) title.textContent = np.title;
|
||||||
const fill = $("np-prog-fill") as HTMLElement | null;
|
if (by) by.textContent = np.album ? `${np.artist} · ${np.album}` : np.artist;
|
||||||
const total = np.durationMs;
|
if (agoEl) agoEl.textContent = np.isPlaying ? "" : np.playedAt ? ago(np.playedAt) : "";
|
||||||
|
|
||||||
if (np.isPlaying && np.progressMs != null && total) {
|
card.classList.toggle("np--live", np.isPlaying);
|
||||||
if (dur) dur.textContent = `${mmss(np.progressMs)} / ${mmss(total)}`;
|
card.href = `https://open.spotify.com/search/${encodeURIComponent(`${np.title} ${np.artist}`)}`;
|
||||||
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) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
section.hidden = false;
|
card.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTop(tracks: Track[]): void {
|
interface Row {
|
||||||
const section = $("top-section");
|
k: string;
|
||||||
const mosaic = $("mosaic");
|
sub?: string;
|
||||||
const caps = $("mosaic-caps");
|
v: string;
|
||||||
if (!section || !mosaic || !caps || !tracks.length) return;
|
w?: number;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockData() {
|
function fillList(listId: string, blockId: string, rows: Row[]): void {
|
||||||
const art = (n: number) => `https://picsum.photos/seed/np${n}/200`;
|
const list = $(listId);
|
||||||
const mk = (n: number): Track => ({
|
const block = $(blockId);
|
||||||
title: ["After Party", "Gemstone", "Heartless", "Breathe", "ONE CALL", "Alright", "Nights", "Ivy", "Self Control"][n],
|
if (!list || !block || !rows.length) return;
|
||||||
artist: ["Don Toliver", "Don Toliver", "Kanye West", "Yeat", "Rich Amiri", "Kendrick Lamar", "Frank Ocean", "Frank Ocean", "Frank Ocean"][n],
|
list.innerHTML = rows
|
||||||
url: "https://open.spotify.com",
|
.map((r) => {
|
||||||
art: art(n),
|
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)} ×`,
|
||||||
|
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)} ×`,
|
||||||
|
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 {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
nowPlaying: { ...mk(0), isPlaying: true, progressMs: 78000, durationMs: 175000, playedAt: null },
|
nowPlaying: {
|
||||||
top: Array.from({ length: 9 }, (_, i) => mk(i)),
|
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";
|
type MusicState = "ready" | "idle" | "off";
|
||||||
|
|
||||||
async function load(): Promise<MusicState> {
|
async function load(): Promise<MusicState> {
|
||||||
let data: any;
|
let data: Data;
|
||||||
try {
|
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
|
data = mm
|
||||||
? await 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(),
|
r.json(),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@ -147,43 +211,42 @@ async function load(): Promise<MusicState> {
|
|||||||
}
|
}
|
||||||
if (location.search.includes("mockmusic")) data = mockData();
|
if (location.search.includes("mockmusic")) data = mockData();
|
||||||
if (!data || !data.configured) return "off";
|
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");
|
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;
|
trigger.hidden = false;
|
||||||
const label = $("music-label");
|
const label = $("music-label");
|
||||||
|
const sub = $("music-sub");
|
||||||
const artEl = $("music-art") as HTMLImageElement | null;
|
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) {
|
if (artEl && np?.art) {
|
||||||
artEl.src = cover;
|
artEl.src = np.art;
|
||||||
artEl.hidden = false;
|
artEl.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = $("music-sub");
|
|
||||||
const bar = $("music-bar");
|
|
||||||
const fill = bar?.firstElementChild as HTMLElement | null;
|
|
||||||
|
|
||||||
if (label) {
|
if (label) {
|
||||||
if (np?.isPlaying) {
|
if (np?.isPlaying) {
|
||||||
trigger.classList.add("music__trigger--live");
|
trigger.classList.add("music__trigger--live");
|
||||||
label.textContent = clamp(np.title);
|
label.textContent = clamp(np.title);
|
||||||
trigger.title = `${np.title} — ${np.artist}`;
|
trigger.title = `${np.title} — ${np.artist}`;
|
||||||
if (sub) sub.textContent = np.artist;
|
if (sub) sub.textContent = np.artist;
|
||||||
if (bar && fill && np.progressMs != null && np.durationMs) {
|
} else if (np) {
|
||||||
fill.style.setProperty("--p", `${Math.min(100, (np.progressMs / np.durationMs) * 100)}%`);
|
|
||||||
bar.hidden = false;
|
|
||||||
}
|
|
||||||
} else if (np?.playedAt) {
|
|
||||||
label.textContent = "last played";
|
label.textContent = "last played";
|
||||||
trigger.title = `${np.title} — ${np.artist}`;
|
trigger.title = `${np.title} — ${np.artist}`;
|
||||||
if (sub) sub.textContent = `${clamp(np.title, 26)} · ${np.artist}`;
|
if (sub) sub.textContent = `${clamp(np.title, 26)} · ${np.artist}`;
|
||||||
} else {
|
} else {
|
||||||
label.textContent = "top tracks";
|
label.textContent = "music";
|
||||||
if (sub) sub.textContent = "last 4 weeks";
|
if (sub) sub.textContent = `${fmt(data.weekPlays)} plays this week`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "ready";
|
return "ready";
|
||||||
@ -200,7 +263,7 @@ const apply = (state: MusicState) => {
|
|||||||
trigger.hidden = false;
|
trigger.hidden = false;
|
||||||
trigger.disabled = true;
|
trigger.disabled = true;
|
||||||
const label = document.getElementById("music-label");
|
const label = document.getElementById("music-label");
|
||||||
if (label) label.textContent = "not playing";
|
if (label) label.textContent = "not scrobbling";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -60,7 +60,7 @@ body {
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
max-width: var(--col);
|
max-width: var(--col);
|
||||||
margin-inline: auto;
|
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 {
|
::selection {
|
||||||
@ -204,9 +204,10 @@ img {
|
|||||||
.topbar {
|
.topbar {
|
||||||
margin: 0 0 clamp(2rem, 5vw, 3rem);
|
margin: 0 0 clamp(2rem, 5vw, 3rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 0.6rem 1rem;
|
||||||
}
|
}
|
||||||
.mark {
|
.mark {
|
||||||
font-family: var(--display);
|
font-family: var(--display);
|
||||||
@ -550,6 +551,116 @@ img {
|
|||||||
color: var(--accent);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation-duration: 0.001ms !important;
|
animation-duration: 0.001ms !important;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user