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)Overview
I wanted a music overlay for OBS that I could set up once and then forget about. The wishlist was pretty specific: it had to work with private Last.fm accounts, carry all its styling in a shareable URL, look right in both dark and light scenes, hide cleanly when playback stops, and survive the weird OBS setups people actually run, crops, filters, browser sources stacked on browser sources.
The hosted version runs 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 stores the entire config in the URL hash, with the editor keeping draft work in localStorage.
The result is a self-contained /w#<base64> overlay page. Paste that URL into OBS as a browser source and you’re done. Layout, colors, shadows, visibility rules, and an optional Last.fm session key all travel in the hash. No database, no viewer cookies, no server-side user state.
Why I Built It
The very first version was a hard-coded component that hit Last.fm’s recent track endpoint, and its weak points showed up 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 push the text colors into unreadable contrast.
Those problems shaped the core 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, but the styling surface got dramatically better.
System Overview
The app breaks down into ten main pieces.
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 sidesteps 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 chose to bake in. More on that in a second.
Data And Config Model
WidgetConfig is the core contract. The editor writes it, the overlay reads it, and the encoding layer just ferries that 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 }}The contract is theme-first and has room to grow. JSON encoded to Base64 is plenty for now, so compression can wait until it’s actually needed.
Flow From Editor To Overlay
The happy path: open / and 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 and paste it into OBS.
The overlay page reads the hash and renders. No server-side session state involved anywhere.
For OBS sizing, the practical range is 600 to 900 pixels wide and 140 to 220 tall, depending on layout. The page background is transparent, so most scenes need basically zero extra setup.
Private Profile Support
Private Last.fm support hangs off that optional sessionKey. After you authenticate, the editor stores the key locally and injects it into the encoded widget URL when you opt in.
The overlay then uses the key for its API requests. You can also strip the key back out before sharing a public-safe version of your design.
The useNowPlaying Hook
useNowPlaying is what keeps the overlay readable and stable. It polls /api/lastfm/recent, and occasionally /trackInfo, with a fast cadence during active playback and a slower one when things are idle.
That adaptive cadence is the whole trick. Active tracks poll often enough to feel live, while idle and paused states back way off, so the overlay isn’t hammering the API for no reason.
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 considered WebSockets and decided against them. Polling plus local estimation is accurate enough for a now-playing overlay, and it’s a lot less machinery.
Where This Design Works Well
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. The editor and widget keep cleanly separate jobs. Even failure is tidy: missing data can just hide the widget instead of leaving broken markup on screen.
Setup Guide
Running it locally is 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, and copy the generated URL.
- Paste the overlay URL into OBS.
That’s clone to working browser source.
Odd Stream Setups
Some scenes need small adjustments. A vertical stack works with direction=vertical and a smaller cover size. A cropped filter wants some 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 trivial: copy the URL, change the fields for the new scene, done. On slower remote setups you can reduce the poll rate or turn off progress estimation.
Deep Editing Guide
The project is easiest to extend when config, editor, and widget stay in their lanes. Here’s how the common extensions play out.
Add A New Theme Token
For a badge or any small theme field, extend WidgetConfig, add a default, add an editor control, and 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 of the design.
Add Animation On Track Change
Add a 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 track change.
Adjust 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.
Swap Data Source
Want Spotify instead? Add a useSpotifyNowPlaying.ts with the same return shape, add source: 'lastfm' | 'spotify' to config, and switch the hook choice in the overlay.
The important part is keeping the runtime contract stable. The overlay shouldn’t care which service supplied the track.
Add Outline Text Mode
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(',')}Add Safe Mode
Set a failed flag after API trouble and render a placeholder, or nothing. Predictable under network trouble beats clever.
Add A Secondary Info Line
For a scrobble count or similar, extend config with showScrobbleCount, add a cached endpoint for user.getInfo, then render the value under the artist line when enabled.
Add 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.
Support Multi-Instance Embeds
Want a wide version and a compact version on different scenes? Copy the link and change only the layout fields, keeping the same session key if you need it. The overlay format already handles this for free.
Strip Session Keys From Public Links
Add a copy option that clears the key before encoding:
const safeConfig = { ...cfg, sessionKey: undefined }const safeUrl = encodeConfig(safeConfig)Image Proxy Notes
The proxy 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.
Failure Modes And Handling
Failure handling stays deliberately simple. 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 for that. 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.
Useful Simplifications
A few choices did a lot of work here. Hash-only state removes all database work. LocalStorage gives the editor resilience without a server. Adaptive polling plus estimated progress avoids a heavier transport entirely. Per-element shadows give precise styling without duplicating components. And funneling everything through one useNowPlaying hook gives future data sources a clean path in.
Problems Faced
Private scrobbles just disappeared 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 heuristic 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 artifact. And scene contrast got noticeably better once accents and fallback text colors moved under one theme object.
What Comes Next
The upgrade list is pretty clear: 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 And Streaming Tips
A few things 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.
Deployment
Deploy to Vercel, add the Last.fm env vars, and 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.
Closing
This overlay treats the link as the main artifact, and that one decision keeps the whole thing shareable, easy to fork, and easy to reason about.
Deep theming plus private profile support makes it genuinely flexible, all without a hosted dashboard or any server-side state.