I love Seattle's KEXP 90.3 FM. I wanted to take it with me — and share it with everyone.
This is a single, dependency-free web component that streams KEXP live, shows what's playing right now, and lets you ❤️ the songs you don't want to forget. Drop one tag into any page — or install it in your browser toolbar — and you've got the best radio station on earth.
<audio-player></audio-player>
<script type="module" src="./audioPlayer.js"></script>It started after I kept seeing Reddit threads of people struggling to stream KEXP outside the official site. I reached out to the KEXP team — they kindly gave me permission to use their API and even pointed me at a better stream source. This project is a thank-you letter to them. If you use it, support KEXP — and send me a link, I'd genuinely love to see where it ends up.
One card. Press it, music plays, the bars dance. The marquee below polls KEXP's API for the current track and only scrolls when the title actually overflows — at a constant speed, no matter how long the song name is.
The heart sits in the card's corner. Hit it and it double-flips on its X axis
with a motion blur while a ring and confetti burst out — the full Twitter
treatment. Likes persist in localStorage, keyed to an anonymous device ID.
Air breaks aren't likeable (the API reports them with no artist/song), so your
playlist never fills up with undefined — undefined. Ask me how I know.
The ♥ 4 chip under the player is both your like counter and a door: click it
and the KEXP card flips over in 3D and expands into a phone-sized panel with
every song you've liked. Remove songs (with an inline "Remove? Yes / Cancel" —
no browser popups), or type any email address and send yourself the list.
npm install
npm run dev # http://localhost:5173| Attribute | Default | What it does |
|---|---|---|
stream-url |
https://kexp.streamguys1.com/kexp160.aac |
Audio stream source |
volume |
0.5 |
Playback volume, clamped to 0–1 |
poll-interval |
15000 |
Now-playing refresh (ms) |
backend-url |
— | Supabase project URL (optional) |
backend-key |
— | Supabase publishable key (optional) |
play()/pause()/toggle()— playback controltoggleLike()— like/unlike the current songisPlaying,isLiked,currentPlay— current state (read-only)playlist— every liked track as{ artist, song, airdate, likedAt }deviceId— stable anonymous ID for this browser
| Event | detail |
|---|---|
playing-changed |
{ isPlaying } |
track-changed |
{ artist, song, airdate } |
like-changed |
{ liked, artist, song, airdate, deviceId, playlistSize } |
player-error |
{ message } |
Everything visual is a custom property — restyle it without touching the component:
audio-player {
--player-accent: #ffb703; /* equalizer bars, focus rings */
--player-like: #f91880; /* the heart */
--player-radius: 20px;
}Tokens: --player-bg, --player-surface, --player-surface-hover,
--player-accent, --player-like, --player-text, --player-muted,
--player-error, --player-radius.
For structural styling, shadow parts are exposed: player, front, back,
button, button-text, logo, like, menu, menu-close, display,
marquee, playlist, error.
audio-player::part(button):hover {
box-shadow: 0 4px 24px rgb(255 90 30 / 35%);
}No framework, no dependencies — one custom element with shadow DOM. That wasn't minimalism for its own sake: the goal is KEXP everywhere, and a zero-dependency component drops unchanged into a static page, a React app, a browser extension popup, or a Tauri menu-bar app.
Some decisions worth explaining:
Render once, mutate surgically. The DOM is built from a single <template>
clone; state changes update textContent and attributes. An earlier version
rebuilt everything with innerHTML every 15 seconds — which restarted animations
and dropped focus mid-interaction. Never again.
Constructable Stylesheets. Styles live in one CSSStyleSheet shared by every
instance via adoptedStyleSheets — parsed once, not per-player.
The marquee is a Web Animations API animation, in pixels. CSS-class marquees
animate in percentages, so long titles scroll faster than short ones. Here the
keyframes are computed from measured widths (containerWidth → -textWidth), so
every title travels at the same px/sec. No void offsetWidth reflow hacks to
restart it, either — cancel() and animate() again.
container-type: inline-size on the host. The component's width follows the
space it's given, not its content. Without this, a long song title silently
widens the whole component and the marquee can never detect overflow — a bug the
original version of this project worked around without understanding. Size
containment fixes the cause.
The heart is a sibling, not a child. It looks like it's inside the play button, but nested buttons are invalid HTML and confuse keyboard and screen-reader users. It's an absolutely-positioned sibling — clicks physically can't reach the play button underneath.
The burst centers itself with the CSS translate property. Particle flight
animates transform; centering lives on the separate translate property, so
they compose instead of overwriting each other. (The first version was 2px
off-center because a border made the ring's box bigger than its width — the
margin-offset trick silently broke.)
The card flip respects people. The hidden face gets the inert attribute —
no ghost tab stops, nothing announced twice. Focus moves to the revealed face.
And everything — bars, marquee, burst, flip — checks prefers-reduced-motion
and sits still for users who ask for that.
Likes are local-first, with an optional cloud behind them. localStorage
plus an anonymous device UUID. No account, no cookie banner. Point the player at
a Supabase project and likes also sync up — your playlist survives a cleared
cache, every song shows its global like count under the heart, and your device's
cloud playlist merges back in on load:
<audio-player
backend-url="https://your-project.supabase.co"
backend-key="sb_publishable_...">
</audio-player>The backend is anonymous by design: the device UUID acts as a bearer capability.
Anyone can insert a like; raw rows are unreachable through the API (no SELECT
or DELETE policies — a bulk-delete attempt is a no-op), and reads/removals only
happen through Postgres functions scoped to a single device. Global numbers are
exposed as aggregates only. The whole client is plain fetch against PostgREST —
no SDK. Schema lives in supabase/migrations/; run it locally with
supabase start. If the backend is down or unconfigured, the player doesn't
care — everything still works locally.
npm test # Playwright: chromium + firefox + webkit, auto-starts Vite
npm run test:ui # interactive modeFifteen tests run against all three engines on every push, with the KEXP API fully mocked for determinism. The cross-browser matrix earns its keep: it caught a Chromium-only launch flag that macOS WebKit silently ignored but Linux WebKit refused to start with — invisible on my machine, fatal in CI.
KEXP in your toolbar. Hit play, close the popup — the music keeps going. Open a popup in another window and it's already in sync: same track, same playing state, same playlist. (That screenshot is a real capture — Sonic Youth happened to be on air. Thanks, KEXP.)
The trick is a split inside the component: an engine (playerEngine.js —
stream, polling, likes; zero DOM) and a shell (everything visual). On a web
page they're fused and you'd never know. In the extension, one engine lives in
a Chrome offscreen document — browser-wide, not per-tab — and every popup is
just a remote control speaking chrome.runtime messages, hosting the exact same
<audio-player> with a proxy engine injected:
document.querySelector('audio-player').engine = new RemoteEngine(state);One engine, one stream, every window in sync — and your liked-song count rides along as a pink badge on the toolbar icon. The whole extension is ~32 KB of JavaScript.
The extension also syncs with the website: a tiny content script on
davidpuerto.com/kexp hands the site's anonymous device-id to the extension,
which adopts it — from then on, a heart in the toolbar and a heart on the site
land in the same cloud playlist (and any likes you'd already collected in the
toolbar get carried across).
npm run build:extension
# then chrome://extensions → Developer mode → Load unpacked → dist-extension/KEXP in your macOS menu bar. A Tauri tray app: click the
icon, the same <audio-player> drops down in a webview wired to the same
hosted backend — likes from the menu bar land in the same per-device playlist
as the website and extension. Click away and it dismisses like a real
menu-bar dropdown; the music keeps playing (the webview outlives the window).
This is the engine/shell split's easiest win: the webview is the host, the exact same component the web page uses, with zero glue code — the only native code is ~60 lines of Rust for the tray icon and window positioning.
npm run tauri build
# → src-tauri/target/release/bundle/macos/KEXP.app- Store listings — Chrome Web Store and Firefox AMO
- Song enrichment — YouTube links for liked tracks
- Live at
davidpuerto.com/kexp
To the KEXP team for saying yes, and for being the kind of station worth building shrines to. Donate to KEXP.
MIT licensed — take it, embed it, share it.



