A Customizable Last.fm Now Playing Overlay
Try it live (opens in a new tab)
/ 10 min read
Overview
I wanted a music overlay for OBS: set it once, then forget it. The requirements were clear. It had to work with private Last.fm accounts, carry all styling in a shareable URL, fit dark and light scenes, hide cleanly after playback stops, and survive awkward OBS setups like crops, filters, and layered browser sources.
Hosted overlays kept missing one of those needs. Some blocked private accounts. Some put theme work behind a dashboard. Some made tiny style edits feel heavier than they should.
I built a Next.js widget that stores the whole config in the URL hash. The editor keeps draft work in localStorage.
The result is a self-contained /w#<base64> overlay page. Paste that URL into OBS as a browser source. Layout, colors, shadows, visibility rules, and an optional Last.fm session key 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 called Last.fm’s recent track endpoint. The weak points showed up fast.
Private accounts returned nothing. Scene variants needed manual CSS edits. Small font or shadow changes meant code changes. Paused playback looked stale. Album art sometimes pushed text colors into poor contrast.
Those problems led to a few 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 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 changes and regenerates the share URL. 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 avoids mixed-content problems. Hide-on-pause logic keeps the overlay off screen when needed. LocalStorage keeps the last editor state between sessions.
The system does not persist widget state on the server. Share the URL, and the other person gets the same theme. Share a hash with a private session key, and they 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 moves 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 ready for more fields. JSON encoded to Base64 is enough right now. Compression can wait.
Flow From Editor To Overlay
Open / and load defaults or a saved local copy. Enter a Last.fm username. Connect Last.fm if you need 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 without server-side session state.
For OBS, the practical range is 600 to 900 pixels wide and 140 to 220 pixels tall. The exact size depends on layout. The page uses a transparent background, so most scenes need little extra setup.
Private Profile Support
Private Last.fm support uses an optional sessionKey. After authentication, the editor stores the key locally and injects it into the encoded widget URL when you choose that option.
The overlay uses the key for API requests. You can strip the key prior to sharing a public-safe version.
That key is not encrypted. A person with the hash gets the same API access as the overlay. 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 fast cadence for active playback and a slower cadence for idle periods.
It estimates playback progress locally and smooths updates, so Last.fm lag does not make the overlay flicker.
The hook returns one state object:
{ track, isLive, isPaused, progressMs, durationMs, percent, isPositionEstimated}WebSockets were not needed. Polling plus estimation is accurate enough for a now-playing overlay.
Where This Design Works Well
The full widget state lives in the URL, so the overlay stays portable. A link is the backup artifact.
Adding a theme field is fast. The same config object feeds the editor, encoder, and widget. Private account support works without a hosted auth portal. The editor and widget keep separate jobs. Failure paths stay clean, since missing data can hide the widget instead of leaving broken markup on screen.
Setup Guide
The local setup is short:
- 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 gets the widget from clone to working browser source.
Odd Stream Setups
Some scenes need small adjustments. A vertical stack can use direction=vertical and a smaller cover size. A cropped filter can use extra outer padding. Low-bitrate scenes often need heavier fonts and stronger shadows. Busy backgrounds need a solid, semi-opaque background mode.
Multiple scene themes are easy. Copy the URL and change the fields for the new scene. On slower remote setups, reduce the poll rate or turn off progress estimation.
Deep Editing Guide
The project is easiest to extend when config, editor, and widget stay separate.
Add A New Theme Token
For a badge or 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, since the config contract owns the whole state surface.
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 animate only on a real track change.
Adjust Polling Strategy
Move hard-coded timing values in useNowPlaying.ts into fastPollMs and idlePollMs. After those values live in config, the editor can expose them in an advanced panel.
Swap Data Source
For Spotify support, add useSpotifyNowPlaying.ts with the same return shape. Add source: 'lastfm' | 'spotify' to config, then switch the hook choice in the overlay.
Keep the runtime contract stable. The overlay should not care which service supplied the track.
Add Outline Text Mode
Outline text can be another shadow mode. This helper creates 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. Render a placeholder state or nothing.
That keeps the overlay predictable under network trouble.
Add A Secondary Info Line
For a scrobble count or a related field, 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 enough for a preset picker.
export const presets = { neon: {...}, minimal: {...}, card: {...}}The editor can merge one preset into the current config and keep the normal URL flow.
Support Multi-Instance Embeds
For wide and compact versions on different scenes, copy the link and change only the layout fields. Keep the same session key if needed.
The overlay format already supports this.
Strip Session Keys From Public Links
Add a copy option that clears the key prior to 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. It leaves room for caching, resizing, and fallback images later.
It reduces direct Last.fm CDN exposure a bit, which has privacy value.
Failure Modes And Handling
The current failure handling stays 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 can make the URL too large. Compression is the future fix for that case.
Security And Privacy
The session key is convenience, not encryption. All config is client-side, and the project does not collect analytics by default.
For a public fork, document the session key risk clearly. For more privacy, add a setting that hides title or artist text during live playback.
Useful Simplifications
Hash-only state removes database work. LocalStorage gives the editor resilience without a server. Adaptive polling plus estimated progress avoids a heavier transport. Per-element shadows give precise styling without duplicate components. One useNowPlaying hook gives future data-source work a clean path.
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 the pause heuristic arrived.
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 after accents and fallback text colors moved under one theme object.
What Comes Next
The next upgrades are 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
For crisp text on downscaled scenes, set the browser source to the final canvas size and avoid double scaling. Rounded album art needs only a small CSS change. Reuse accent colors across your overlay and chat theme for visual consistency.
Turn on refresh-on-active only when scene switching leaves stale state. Add a darker semi-opaque background mode when HDR or bright scenes wash the widget out.
Deployment
Deploy to Vercel. Add the Last.fm env vars. Add caching headers to /api/proxy-image if you want them. Use the production URL in OBS instead of localhost.
Do not commit a personal session key in a fork.
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 a hosted dashboard or server-side state model.