Skip to content

dapinitial/KEXP-WebComponent

Repository files navigation

KEXP, everywhere I go

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.


The player

The player — KEXP card with equalizer bars, heart, and now-playing marquee

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.

Like what you hear

The heart mid-burst — ring expanding, confetti flying

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.

Your playlist, on the flip side

The card flipped over — liked songs, remove buttons, email form

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.


Using it

npm install
npm run dev        # http://localhost:5173

Attributes

Attribute Default What it does
stream-url https://kexp.streamguys1.com/kexp160.aac Audio stream source
volume 0.5 Playback volume, clamped to 01
poll-interval 15000 Now-playing refresh (ms)
backend-url Supabase project URL (optional)
backend-key Supabase publishable key (optional)

Properties & methods

  • play() / pause() / toggle() — playback control
  • toggleLike() — like/unlike the current song
  • isPlaying, isLiked, currentPlay — current state (read-only)
  • playlist — every liked track as { artist, song, airdate, likedAt }
  • deviceId — stable anonymous ID for this browser

Events

Event detail
playing-changed { isPlaying }
track-changed { artist, song, airdate }
like-changed { liked, artist, song, airdate, deviceId, playlistSize }
player-error { message }

Theming

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%);
}

How it's built, and why

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.

Testing

npm test          # Playwright: chromium + firefox + webkit, auto-starts Vite
npm run test:ui   # interactive mode

Fifteen 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.

The browser extension

The extension popup — Sonic Youth live on KEXP, one song liked

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/

The menu-bar app

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

Where this is going

  • Store listings — Chrome Web Store and Firefox AMO
  • Song enrichment — YouTube links for liked tracks
  • Live at davidpuerto.com/kexp

Thanks

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.

About

KEXP 90.3 FM Seattle. Live Steaming Audio Player. Browser Extensions+WebComponent so you can place it wherever you like. Stream KEXP from wherever you are, across tabs, across apps!

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors