skip to content

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.

11 min read
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 OBS setups people actually 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 turned a font-size change into a five-click round trip through a settings page.

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 first version was a hard-coded component that hit Last.fm’s recent track endpoint. It broke on the first stream I used it for.

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 shaped the pieces that exist now. WidgetConfig, lossless encode and decode helpers, per-element shadow tools, adaptive polling, and a session key path for private profiles. The scope stayed small and the styling options got a lot wider.

The pieces

Ten of them.

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.

The server never persists widget state, and that omission is the design. 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. I get to that below.

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 at this size, so compression waits until a config gets long enough to break a URL.

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 no setup past dropping the URL in.

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.

Varying the speed is what makes this work at all. Active tracks poll often enough to feel live, and idle or paused states back off so the overlay stops spending API budget on a song that is not playing.

Requests per minute by playback state
Loading chart…

It also estimates playback progress locally and smooths updates, so a slow Last.fm response leaves the progress bar moving instead of stuttering.

Everything comes back as one state object:

{
track,
isLive,
isPaused,
progressMs,
durationMs,
percent,
isPositionEstimated
}

I looked at WebSockets and passed. Polling plus local estimation lands the progress bar within a second, and it is a lot less machinery to own.

Where this design pays off

Because the full widget state lives in the URL, the overlay is 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. Failure is tidy too. Missing data hides the widget instead of leaving broken markup on screen.

Running it locally

Quick:

  • Clone the repo.
  • Create .env.local with LASTFM_API_KEY and LASTFM_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.

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 every piece of widget state lives in that one config object. That is 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.

What matters is keeping the return shape stable. The overlay should not care which service handed it the track.

Outline text

Outline text is another shadow mode wearing a different name. 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. A blank overlay during an outage looks better on stream than a half-rendered one.

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.

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 keeps viewers from hitting the Last.fm CDN directly, so their IPs stay out of someone else’s logs.

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:

Share URL length as config grows, characters
Loading chart…

Compression is the eventual fix. Open the table view on the chart for the character counts behind each step.

Security and privacy

The security model is short. 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. 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.