Overview
I wanted a “drop‑it‑in and forget it” music overlay that:
- Doesn’t require logging into a dashboard every boot
- Works with private Last.fm accounts
- Is styled entirely via a shareable URL
- Looks good over dark/light scenes
- Hides itself gracefully when paused
- Handles odd OBS / layering workflows (cropping, blending, filters)
Nothing I found hit all of those and most SaaS overlays gate private scrobble access or hide themes behind paywalls. So I built a Next.js widget that encodes its full config in the URL hash (no server persistence) plus localStorage caching for edits.
Outcome: a self‑contained /w#<base64> overlay page you can paste into OBS as a browser source. Theme changes, positioning, text shadows, conditional visibility, and even a private session key are in that hash. No database. No cookies needed for viewers.
Why I Built It
Initial Idea: a hard‑coded component hitting Last.fm’s recent track endpoint. Here are the starting issues:
- Private accounts returned nothing (needed session key)
- Couldn’t reuse the same overlay across scenes with minor variants
- Manual CSS edits every time I wanted a subtle font or shadow tweak
- Paused music with no way of catching or changing
- Color accents clashed with album art (needed controlled theming)
So: I introduced a structured WidgetConfig, a lossless encode/decode layer, per‑element shadow helpers, adaptive polling, and a session key pass‑through for private profiles.
Scope stayed intentionally small: just enough primitives to style everything without rewriting components.
System Overview
Core building blocks:
WidgetConfigType – authoritative shape for all theme + behavior- Base64 Hash Encoding –
/#w#<b64>carries config (client only) - Local Editor (
/) – interactive form that previews + regenerates share URL - Runtime Overlay (
/w) – pure reader; decodes hash and renders - Private Profile Support – optional embedded Last.fm
sessionKey useNowPlayingHook – adaptive polling + progress estimation- Per‑Element Text Shadow Utilities – fine‑grained shadow toggles
- Image Proxy Route – avoids mixed content / CORS + future caching
- “Hide When Paused” Logic – renders a zero‑footprint state
- LocalStorage Persistence – QOL: reopen and continue styling
Nothing is persisted server‑side share the URL and others see exactly your theme (except private scrobbles unless you intentionally include your session key).
Data & Config Model
Example (shortened) WidgetConfig excerpt:
interface WidgetConfig { lfmUser: string; sessionKey?: string | null; // Private profile access 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; };}Minimal, theme‑first, forward‑extendable. Hash includes only JSON to Base64 (no compression yet—small enough).
Flow (Build → Share → Display)
- Open
/– editor loads defaults OR localStorage copy - Enter Last.fm username (auto‑fills if connected privately)
- (Optional) Connect + authorize to store
sessionKey - Adjust colors / shadows / fonts / layout
- Share URL auto‑updates (
/w#<b64>) - Paste that URL into OBS Browser Source:
- Width: 600–900 (varies with layout)
- Height: 140–220
- Enable “Refresh browser when scene becomes active” if rotating scenes
- Set CSS if needed:
body { background: rgba(0,0,0,0); }(already transparent)
- Music changes → overlay auto updates
- Paused? If configured, overlay collapses (no awkward stale display)
Private Profile Support
- When you authenticate, a
sessionKeyis stored locally - Editor injects it into the encoded widget URL
- Overlay uses it for scrobble API calls if present
- You can strip it before sharing a “public safe” variant
Important: anyone with a hash containing your session key can see what the API would return for you. Treat it like a lightweight token—avoid posting publicly unless you’re fine with that.
The useNowPlaying Hook
Responsibilities:
- Poll
/api/lastfm/recent(optionally/trackInfo) with dynamic cadence:- Faster while track is active
- Slower during inactivity
- Generate an estimated progress bar by locally tracking elapsed ms
- Stabilize updates to prevent UI flicker when Last.fm lags
- Expose:
{ track, // { name, artist, album, imageUrl, nowPlaying?, ... } isLive, // bool - currently “nowplaying” isPaused, // heuristics (no “nowplaying” + same timestamp) progressMs, durationMs, percent, isPositionEstimated}Design choice: no websockets—polling + estimation = sufficient fidelity for music overlays.
Where It Shines
- Zero server persistence (portable + privacy‑friendly)
- Entire state migrates via a link (excellent for scenes / backups)
- Fast iteration: add a theme field → encode → auto-shipped
- Private account support without building an auth portal
- Clear separation: Editor (stateful) vs Widget (pure)
- Stream overlay friendly: transparency, compactness, low CPU
- Extensible theming (can add outlines, gradients, animations later)
- Safe failure modes (no API = clean hidden state instead of broken markup)
Setup Guide (Fresh Clone)
- Clone repo
- Create
.env.local:LASTFM_API_KEY=xxxxLASTFM_API_SECRET=yyyy - Install deps:
npm install
- Run dev:
npm run dev
- Open
http://localhost:3000 - (Optional) Click “Connect Last.fm” → authorize → returns with session stored
- Enter username (auto if authenticated)
- Tweak styling → copy share link
- Paste into OBS Browser Source:
- Width: 600–900 (varies with layout)
- Height: 140–220
- Enable “Refresh browser when scene becomes active” if rotating scenes
- Set CSS if needed:
body { background: rgba(0,0,0,0); }
Adapting to “Weird” Stream Setups
| Situation | Problem | Tweak |
|---|---|---|
| Vertical scene stack | Overlay too wide | Set direction=vertical, shrink cover |
| Cropped by filter | Edges clipped | Add outer padding div + encode new config |
| Low bitrate stream | Text blur | Increase font weight + enable strong shadow |
| Busy background | Legibility loss | Use solid semi‑opaque background mode |
| Multi‑theme scenes | Need variants | Duplicate URL → change accent per scene |
| Dual monitor / remote machine | Laggy sync | Reduce pollMs or disable progress bar estimation |
Deep Editing Guide (Fork & Extend)
1. Add a New Theme Token
Want a year badge or custom label?
a. Extend WidgetConfig:
// In utils/config.tstheme: { // ...existing badge?: { text: string; bg: string; color: string; };}b. Add defaults to defaultConfig.
c. Update editor form (search for other theme inputs).
d. Render in w.tsx inside the layout:
{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>)}e. Link updates automatically—no backend change.
2. Custom Animation on Track Change
Insert a simple key transition:
const fadeKey = track?.name + track?.artist;<div key={fadeKey} className="transition-opacity duration-300 opacity-100"> {/* existing text */}</div>Or add a diffing usePrevious(track?.mbid) to animate only when truly new.
3. Adjust Polling Strategy
Inside useNowPlaying.ts:
- Introduce a prop
fastPollMsandidlePollMs - Replace constants with config
- Editor can surface an “Advanced” panel later
4. Swap Data Source (Prototype Spotify)
Create useSpotifyNowPlaying.ts with identical return signature.
Add a source: 'lastfm' | 'spotify' field in config.
Switch hook consumption in overlay.
5. Add Outline Option (Pseudo Stroke)
Enhance shadow util: if user picks “outline”, generate multi‑direction stacked shadows. Example:
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. Safe Mode (If API Fails)
Wrap network call → set a failed flag → overlay renders a placeholder like “(No Data)” or hides.
7. Adding a Secondary Line (Scrobble Count)
Extend config: showScrobbleCount: boolean.
Add endpoint to call user.getInfo (cache lightly).
Render under artist line if enabled.
8. Theme Presets
Create a presets.ts:
export const presets = { neon: {...}, minimal: {...}, card: {...}};Add a dropdown in editor to merge a preset into current config.
9. Multi‑Instance Coordination
If you embed twice (e.g., wide + compact), you can:
- Copy link
- Edit layout only
- Keep same session key (scrobbler state reused)
10. Don’t Leak Your Session Key
Add a toggle “Strip session key before copying public link”:
const safeConfig = { ...cfg, sessionKey: undefined };const safeUrl = encodeConfig(safeConfig);Image Proxy Notes
Route: /api/proxy-image?url=...
Reasons:
- Prevent mixed content on HTTPS overlays
- Future: add resizing, caching headers, fallback image
- Avoid leaking direct Last.fm CDN access logs with your scrobble pattern (light privacy value)
Failure Modes & Handling
| Failure | Current Behavior | Future Option |
|---|---|---|
| Last.fm timeout | Overlay hides if no recent track | Show “No data” text |
| Invalid session key | Silent fallback to public | UI badge “Key expired” |
| Username typo | Empty feed | Add inline “User not found” prompt |
| Hash corruption | Decode error → defaults | Show decode error pill |
| Extremely long config | Bloated URL | Optional LZ-based compression |
Security & Privacy
- Session key is NOT encryption—just convenience
- All config is client‑side; no analytics collection by default
- Consider adding a flag to anonymize artist/title (some streamers hide track names until after playback)
- If you fork publicly, document session key risk prominently
Interesting Simplifications
- Hash‑only state (no DB complexity)
- LocalStorage ghost copy (resilience w/o servers)
- Adaptive poll + estimation instead of progress websockets
- Per‑element shadow toggling (microcontrol without text duplication)
- Single
useNowPlayingabstraction (future multi-source friendly)
Problems Faced
| Problem | Symptom | Fix |
|---|---|---|
| Private scrobbles hidden | Empty overlay for private users | Embedded sessionKey support |
| Theme drift | Hard-coded color values scattered | Centralized WidgetConfig + encode/decode |
| Stale paused track | Overlay “lies” that song is active | Hide-on-pause + heuristic for pause detection |
| Font/shadow tinkering friction | Rebuilds for tiny changes | Live editor w/ preview + URL sync |
| Sharing variations | Manual screenshot/logging | Hash becomes the single artifact |
| Bad contrast on some scenes | Unreadable meta text | Accent tokens + fallback colors |
What I’d Add Next
- Optional mini queue mode (show next track if available)
- Responsive container scaling (
scaleparam) - Built‑in theme gallery + preset importer
- Session key obfuscation (not true security, just accident prevention)
- Drag‑to‑reorder text elements in editor
- Auto‑color accent from album art (with contrast threshold)
- Framerate smoothing of progress bar (requestAnimationFrame)
- LZ-based compression for huge future configs
OBS / Streaming Tips
| Goal | Tip |
|---|---|
| Crisp text 1080p → 720p downscale | Set browser source resolution to final canvas size; avoid double scaling |
| Album art rounding | Add CSS override: img { border-radius: 12px !important; } |
| Color match chat or theme | Reuse same accent hex across tools |
| Scene transition stutter | Enable “Refresh when active” only if necessary |
| HDR scenes washed overlay | Add semi‑opaque dark background mode |
Deployment
Simple path:
- Deploy to Vercel (defaults fine)
- Add env vars
- Set caching (optional) for
/api/proxy-image - Protect fork if using personal session key (don’t check it in)
- Use canonical production URL for OBS (avoid localhost caches)
Closing
This overlay leans into “link as artifact” thinking: shareable, forkable, transparent. Private profile compatibility plus theming depth makes it ideal for streamers who want control without another hosted dashboard.
If you fork it:
- Start by reading
utils/config.ts - Keep editor and widget separate
- Don’t add a backend unless you need shared state