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:
WidgetConfig
Type – 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
useNowPlaying
Hook – 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
sessionKey
is 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
fastPollMs
andidlePollMs
- 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
useNowPlaying
abstraction (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 (
scale
param) - 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
- Add one enhancement at a time
- Keep editor and widget separate in concerns
- Don’t prematurely add a backend unless you need shared state
Ship it scrappy. Refine with real scene usage. Let ergonomics, not ideology, drive the next tweak.