skip to content

A Customizable Last.fm Now Playing Overlay

Try it live (opens in a new tab)

/ 11 min read

React , Next.js , Vercel , TypeScript , Tailwind CSS

Overview

I wanted a music overlay I could drop into OBS and forget about. The requirements were plain. It needed to work with private Last.fm accounts, carry its full styling in a shareable URL, hold up over dark and light scenes, hide cleanly when playback stops, and survive awkward OBS setups such as cropping, filters, and layered browser sources.

Most hosted overlays missed at least one of those points. Some blocked private account support. Some hid theming behind a dashboard. Some made small style changes feel heavier than they should. I ended up building a Next.js widget that keeps the whole config in the URL hash and stores editor work in localStorage.

The result is a self-contained /w#<base64> overlay page. You paste that URL into OBS as a browser source. Layout, colors, shadows, visibility rules, and even 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 first version was a hard-coded component that hit Last.fm’s recent track endpoint. The weak points appeared fast. Private accounts returned nothing. Scene variants required manual CSS changes. Small font or shadow tweaks meant another code edit. Paused playback looked stale. Album art sometimes pushed the rest of the colors into bad contrast.

Those problems pushed the project toward a few core pieces. A structured WidgetConfig. Lossless encode and decode helpers. Per-element shadow utilities. Adaptive polling. A way to pass a session key through for private profiles. The scope stayed small, though the styling surface became much better.

System overview

The app has ten main pieces. WidgetConfig defines the full theme and behavior shape. Base64 hash encoding carries the config in the URL. The editor page at / previews and regenerates the share URL. The runtime page at /w only decodes and renders. Private profile support adds an optional sessionKey. useNowPlaying handles polling and playback estimation. Shadow helpers control text styling per field. An image proxy route avoids mixed-content problems. Hide-on-pause logic keeps the overlay out of the way when needed. LocalStorage keeps your last edit around between sessions.

The important part is what the system does not do. It does not persist widget state on the server. If you share the URL, the other person gets the same theme. If the hash includes a private session key, they also get the same Last.fm access you chose to include.

Data and config model

WidgetConfig is the core contract. The editor writes it. The overlay reads it. The encoding layer only has to move 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;
};
}

That contract is intentionally theme-first and forward-friendly. Right now, JSON encoded to Base64 is enough. Compression is not needed yet.

Flow from editor to overlay

The flow is direct. Open / and load defaults or a saved local copy. Enter a Last.fm username. Optionally connect Last.fm to store a session key for a private profile. Adjust theme, layout, and behavior. Copy the generated /w#<b64> URL and paste it into OBS. The overlay page reads the hash and renders with no server-side session state.

For OBS, the practical range is still about 600 to 900 pixels wide and 140 to 220 pixels tall, depending on layout. The page already uses a transparent background, so most scenes need little extra setup.

Private profile support

Private Last.fm support depends on an optional sessionKey. After authentication, the editor stores the key locally and injects it into the encoded widget URL when you choose to use it. The overlay uses that key for API requests. You can also strip the key before you share a public-safe version.

That key is not encrypted. If someone gets a hash that includes it, they get the same API access the overlay gets. Treat it like a convenience token and share with care.

The useNowPlaying hook

useNowPlaying keeps the overlay readable and stable. It polls /api/lastfm/recent, and sometimes /trackInfo, with a faster cadence while a song is active and a slower cadence during idle periods. It estimates playback progress locally and smooths updates so the overlay does not flicker when Last.fm lags.

The hook returns a single state object with the track data, live status, pause status, progress values, and a flag that tells the UI whether the current position is estimated.

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

I did not need websockets for this. Polling plus estimation is accurate enough for a now playing overlay.

Where this approach works well

This design stays portable because the full widget state lives in the URL. It also stays easy to back up because a link is the artifact. Adding a theme field is fast because the same config object feeds the editor, the encoder, and the widget. Private account support works without a heavy auth portal. Editor and widget responsibilities stay separate. Failure paths stay clean because missing data can hide the widget instead of leaving broken markup on screen.

Setup guide

The local setup is short.

  1. Clone the repo.
  2. Create .env.local with LASTFM_API_KEY and LASTFM_API_SECRET.
  3. Run npm install.
  4. Run npm run dev.
  5. Open http://localhost:3000.
  6. Optionally connect Last.fm and store a session key.
  7. Enter a username, tune the theme, and copy the generated URL.
  8. Paste the overlay URL into OBS.

That is enough to get from clone to working browser source.

Adapting to odd stream setups

Some scene layouts need small adjustments. A vertical stack may need direction=vertical and a smaller cover size. A cropped filter may need extra outer padding. Low bitrate scenes often benefit from heavier fonts and stronger shadow settings. Busy backgrounds usually need a solid, semi-opaque background mode. Multiple scene themes are easy because a copied URL is already a new variant. On slower remote setups, reducing the poll rate or turning off progress estimation often helps.

Deep editing guide

The project is easiest to extend when you keep the separation between config, editor, and widget intact.

1. Add a new theme token

If you want a badge or another 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 link updates on its own because the config contract already owns the whole state surface.

2. Add animation on track change

You can 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>

If you want tighter control, compare the current track against usePrevious(track?.mbid) and animate only on a real track change.

3. Adjust polling strategy

Move hard-coded timing values in useNowPlaying.ts into fastPollMs and idlePollMs. Once those live in config, the editor can expose them in an advanced panel.

4. Swap data source

If you want Spotify support, add useSpotifyNowPlaying.ts with the same return shape, add source: 'lastfm' | 'spotify' to config, and switch the hook choice in the overlay. The key is to keep the runtime contract stable.

5. Add outline text mode

Outline text is another shadow mode. A helper like the one below produces 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(',');
}

6. Add a safe mode

If the API fails, set a failed flag and render either a placeholder state or nothing. That keeps the overlay predictable under network trouble.

7. Add a secondary info line

For a scrobble count or similar field, extend config with showScrobbleCount, add a cached endpoint for user.getInfo, and render the value under the artist line when enabled.

8. Add theme presets

A presets.ts file with named theme objects is enough for a preset picker.

export const presets = {
neon: {...},
minimal: {...},
card: {...}
};

The editor can merge one preset into the current config and then keep the normal URL flow.

9. Support multi-instance embeds

If you want both a wide and compact version on different scenes, copy the link, change only the layout fields, and keep the same session key if needed. The overlay format already supports this well.

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=... handles mixed-content problems and leaves room for caching, resizing, and fallback images later. It also reduces direct Last.fm CDN exposure a bit, which has some privacy value.

Failure modes and handling

The current failure handling is 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. An oversized config makes the URL too large, which is where compression would help later.

Security and privacy

The session key is convenience, not encryption. All config is client-side and the project does not collect analytics by default. If you fork the repo publicly, document the session key risk clearly. If you need more privacy, add a setting that hides title or artist text while playback is live.

Useful simplifications

The best simplifications in this project are structural. Hash-only state removes database work. LocalStorage gives editor resilience without a server. Adaptive polling plus estimated progress avoids a heavier transport. Per-element shadow controls give precise styling without duplicating components. One useNowPlaying hook gives future data-source work a clean seam.

Problems faced

Private scrobbles disappeared until session key support landed. Theme values drifted until WidgetConfig became the single source of truth. Paused playback looked stale until hide-on-pause and a pause heuristic came in. Font and shadow tuning was slow until live preview and URL sync took over. Variant sharing was clumsy until the hash became the artifact. Scene contrast improved once accents and fallback text colors were kept under one theme object.

What I would add next

The next upgrades are clear. A small queue mode. A responsive scale option. Built-in theme presets. Session key obfuscation to reduce accidental sharing. Drag-to-reorder controls in the editor. Accent extraction from album art with a contrast check. Smoother progress animation. Compression for larger future configs.

OBS and streaming tips

For crisp text on downscaled scenes, set the browser source to the final canvas size and avoid double scaling. For rounded album art, a small CSS override is enough. Reuse accent colors across your overlay and chat theme when you want visual consistency. Only enable refresh-on-active when scene switching makes stale state a problem. Add a darker semi-opaque background mode when HDR or bright scenes wash the widget out.

Deployment

Deployment is simple. Push to Vercel, add the Last.fm env vars, add caching headers to /api/proxy-image if you want them, and use the production URL in OBS instead of localhost. If your fork includes a personal session key, do not commit that value.

Closing

This overlay treats the link as the main artifact. That keeps the widget shareable, easy to fork, and easy to reason about. Theming depth and private profile support make the tool flexible without forcing a hosted dashboard or server-side state model.

Repo: https://github.com/oyuh/music-widget