A Customizable Last.fm Now Playing Overlay
How I built a deeply themeable Last.fm now playing overlay with URL-based config, private profile support, and no server-side state.
Try it live (opens in a new tab)What I wanted
A music overlay for OBS I could set up once and then never think about again. The wishlist was pretty specific: work with private Last.fm accounts, carry all its styling in a shareable URL, look right in dark and light scenes, hide cleanly when playback stops, and survive the genuinely weird OBS setups people run. Crops, filters, browser sources stacked on browser sources, all of it.
Hosted version’s at fast.jamlog.lol.
Every hosted overlay I tried missed at least one of those. Some blocked private accounts. Some locked theming behind a dashboard. Some made tiny style edits feel like way more work than they should be.
So I built a Next.js widget that stuffs the entire config into the URL hash, with the editor keeping draft work in localStorage.
What you end up with is a self-contained /w#<base64> page. Paste that into OBS as a browser source and you’re done. Layout, colors, shadows, visibility rules, and an optional Last.fm session key all ride in the hash. No database, no viewer cookies, no server-side user state.
Where it came from
The very first version was a hard-coded component that hit Last.fm’s recent track endpoint, and it fell apart almost immediately.
Private accounts returned nothing. Every scene variant needed manual CSS edits. Small font or shadow tweaks meant code changes. Paused playback just sat there looking stale. And album art would occasionally shove the text colors into unreadable contrast.
Those problems are what shaped the actual pieces: WidgetConfig, lossless encode and decode helpers, per-element shadow tools, adaptive polling, and a session key path for private profiles. The scope stayed small, the styling surface got a lot better.
The pieces
Ten of them, roughly.
WidgetConfig defines the full theme and behavior shape. Base64 hash encoding carries that config in the URL. The editor page at / previews changes and regenerates the share URL live. The runtime page at /w decodes and renders.
Private profile support adds an optional sessionKey. useNowPlaying handles polling and playback estimation. Shadow helpers style each text field. An image proxy route gets around mixed-content problems. Hide-on-pause logic keeps the overlay off screen when nothing’s playing. And localStorage holds the last editor state between sessions.
Notice what’s missing: the server never persists widget state. Share the URL and the other person gets the exact same theme. Share a hash that includes a private session key and they also get the same Last.fm access you baked in. More on that in a second.
The config object
WidgetConfig is the contract. The editor writes it, the overlay reads it, and the encoding layer just carries the object between the two pages.
interface WidgetConfig { lfmUser: string sessionKey?: string | null behavior: { hideIfPaused: boolean showAlbumArt: boolean compact: boolean } theme: { accent: string background: { mode: "solid" | "transparent", color: string } text: { title: string | "accent" artist: string | "accent" album: string | "accent" } shadows: { title?: ShadowSpec | null artist?: ShadowSpec | null album?: ShadowSpec | null } fonts: { family: string weightTitle: number weightMeta: number } } layout: { direction: "horizontal" | "vertical" gap: number coverSize: number } advanced: { progressBar: boolean progressBarHeight: number }}Theme-first with room to grow. JSON encoded to Base64 is plenty for now, so compression can wait until it’s actually a problem.
Editor to overlay
Happy path: open /, it loads defaults or your saved local copy. Enter a Last.fm username. Connect Last.fm if you need a session key for a private profile. Tweak theme, layout, and behavior until it looks right. Copy the generated /w#<b64> URL, paste it into OBS.
The overlay page reads the hash and renders. No server-side session state anywhere in that.
For OBS sizing, 600 to 900 pixels wide and 140 to 220 tall is the practical range depending on layout. The page background is transparent so most scenes need basically zero setup.
Private profiles
Hangs off that optional sessionKey. After you authenticate, the editor stores the key locally and injects it into the encoded widget URL if you opt in.
The overlay then uses the key for its API requests. You can also strip it back out before sharing a public-safe version of your design.
The useNowPlaying hook
This is what keeps the overlay readable and stable. It polls /api/lastfm/recent, and occasionally /trackInfo, fast during active playback and slower when things are idle.
Changing the speed like that is the whole trick. Active tracks poll often enough to feel live, idle and paused states back way off so the overlay isn’t hammering the API for nothing.
It also estimates playback progress locally and smooths updates, so a laggy Last.fm response doesn’t make the overlay flicker.
Everything comes back as one state object:
{ track, isLive, isPaused, progressMs, durationMs, percent, isPositionEstimated}I thought about WebSockets and decided against it. Polling plus local estimation is accurate enough for a now-playing overlay and it’s a lot less machinery to own.
Where this design pays off
Because the full widget state lives in the URL, the overlay is completely portable. The link is the backup.
Adding a theme field is fast, since the same config object feeds the editor, the encoder, and the widget. Private accounts work without a hosted auth portal. Editor and widget keep separate jobs. Even failure is tidy: missing data can just hide the widget instead of leaving broken markup on screen.
Running it locally
Quick:
- Clone the repo.
- Create
.env.localwithLASTFM_API_KEYandLASTFM_API_SECRET. - Run
npm install. - Run
npm run dev. - Open
http://localhost:3000. - Connect Last.fm if you want to store a session key.
- Enter a username, tune the theme, copy the generated URL.
- Paste the overlay URL into OBS.
That’s clone to working browser source.
Weird stream setups
Some scenes need small adjustments. A vertical stack wants direction=vertical and a smaller cover size. A cropped filter wants extra outer padding. Low-bitrate scenes usually need heavier fonts and stronger shadows, and busy backgrounds look better with the solid semi-opaque background mode.
Multiple scene themes are easy. Copy the URL, change the fields for the new scene, done. On slower remote setups you can drop the poll rate or turn off progress estimation.
Extending it
The project is easiest to extend when config, editor, and widget stay in their lanes. Here’s how the common ones play out.
Adding a theme token
For a badge or any small theme field: extend WidgetConfig, add a default, add an editor control, render the field in w.tsx.
theme: { badge?: { text: string bg: string color: string }}{cfg.theme.badge && ( <span style={{ background: cfg.theme.badge.bg, color: cfg.theme.badge.color, padding: '2px 6px', fontSize: 11, borderRadius: 4 }} > {cfg.theme.badge.text} </span>)}The share link updates on its own, because the config contract owns the entire state surface. That’s the payoff.
Animating on track change
Keyed transition on the visible track data.
const fadeKey = track?.name + track?.artist<div key={fadeKey} className="transition-opacity duration-300 opacity-100"> {/* existing text */}</div>For tighter control, compare the current track against usePrevious(track?.mbid) and only animate on a real change.
Changing the polling strategy
The timing values in useNowPlaying.ts are hard-coded right now. Move them into fastPollMs and idlePollMs, and once they live in config the editor can expose them in an advanced panel.
Swapping the data source
Want Spotify instead? Add a useSpotifyNowPlaying.ts with the same return shape, add source: 'lastfm' | 'spotify' to config, switch the hook choice in the overlay.
The important bit is keeping the runtime contract stable. The overlay shouldn’t care which service handed it the track.
Outline text
Outline text is really just another shadow mode. This helper builds a stacked pseudo-stroke:
function outline(color: string, r: number) { const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1],[1,-1],[-1,1]] return dirs.map(([x,y]) => `${x*r}px ${y*r}px 0 ${color}`).join(',')}A safe mode
Set a failed flag after API trouble and render a placeholder, or nothing at all. Predictable under network trouble beats clever.
A secondary info line
For a scrobble count or whatever: extend config with showScrobbleCount, add a cached endpoint for user.getInfo, render the value under the artist line when enabled.
Theme presets
A presets.ts file with named theme objects is all a preset picker needs.
export const presets = { neon: {...}, minimal: {...}, card: {...}}The editor merges a preset into the current config and the normal URL flow takes it from there.
Multiple embeds
Want a wide version and a compact version on different scenes? Copy the link, change only the layout fields, keep the same session key if you need it. The format already handles this for free.
Stripping session keys from public links
Add a copy option that clears the key before encoding:
const safeConfig = { ...cfg, sessionKey: undefined }const safeUrl = encodeConfig(safeConfig)The image proxy
The route at /api/proxy-image?url=... exists to dodge mixed-content problems, and it leaves room for caching, resizing, and fallback images later.
It also cuts direct Last.fm CDN exposure a bit, which is a small but real privacy win.
How it fails
Kept dumb on purpose. Last.fm timeouts hide the overlay. Invalid session keys fall back to public data. A bad username returns an empty feed. A corrupt hash falls back to defaults.
The one that can sneak up on you is config size. The hash grows with every theme field, and a session key tacks a long chunk on top:
Compression is the eventual fix. Open the table view on the chart if you want the rough character counts behind each step.
Security and privacy
To be clear about the model: the session key is convenience, not encryption. All config lives client-side, and the project collects no analytics by default.
If you fork this publicly, document the session key risk clearly. And if you want more privacy, a setting that hides title or artist text during live playback is an easy add.
Simplifications that did a lot of work
Hash-only state removes all database work. LocalStorage keeps the editor from losing your work without needing a server. Adaptive polling plus estimated progress means I never had to reach for websockets. Per-element shadows give you fine control without copying components. And funneling everything through one useNowPlaying hook gives future data sources a clean path in.
Problems along the way
Private scrobbles just vanished until session key support landed. Theme values kept drifting until WidgetConfig became the single source of truth. Paused playback looked stale until hide-on-pause and the pause guessing showed up.
Font and shadow tuning was painfully slow until live preview and URL sync took over. Sharing variants was clumsy until the hash became the thing you share. And scene contrast got noticeably better once accents and fallback text colors moved under one theme object.
What comes next
A small queue mode, a responsive scale option, built-in theme presets, session key masking to reduce accidental sharing, drag-to-reorder controls in the editor, album-art accent extraction with a contrast check, smoother progress animation, and compression for larger configs.
OBS tips I learned the hard way
For crisp text on downscaled scenes, set the browser source to the final canvas size and avoid double scaling. Rounded album art is a one-line CSS change. Reusing accent colors across your overlay and chat theme goes a long way for visual consistency.
Only turn on refresh-on-active if scene switching is leaving stale state behind. And if HDR or bright scenes wash the widget out, the darker semi-opaque background mode fixes it.
Deploying
Deploy to Vercel, add the Last.fm env vars, optionally add caching headers to /api/proxy-image. Use the production URL in OBS instead of localhost.
And please don’t commit a personal session key in a fork.