English

Spotify overlay in OBS

TL;DR

Let’s be honest: I only wanted a little overlay in OBS showing what’s playing on Spotify.

Turns out that meant spinning up a Node.js service, figuring out Spotify auth, hooking up WebSocket to OBS, and sprinkling in a few animations. Simple idea, overengineered solution. I love it.

Here’s what I ended up with: a slick, live-updating overlay showing the current song, artist, and album art — all piped in through a custom Node.js backend and displayed in OBS using a browser source.


Spotify OBS Overlay

Features

Before jumping into code, let me outline what this overlay actually does:

  • Live Song Updates

    Every time the track changes on Spotify, the overlay updates instantly. No browser refreshes, no polling. Just real-time WebSocket updates powered by a backend service.


  • Custom Styling

    Since the overlay is rendered with HTML/CSS inside OBS, you have full control over the look. Fancy gradients? Smooth transitions? Rounded album art with a drop shadow? Yes to all.


  • Now Playing Only

    I kept it minimal: it shows the current track info, nothing more. No play/pause buttons, no volume bar. Just what you need to flex your music taste on stream.


The Stack

Let’s talk tech. Here’s what I used:

  • Node.js: A tiny Express server to serve the overlay and pipe data via WebSocket.
  • Spotify Web API: For fetching the current playback data.
  • OBS WebSocket Plugin (optional): Could be used for syncing with scene changes or triggers.
  • HTML/CSS/JS: The overlay itself is just a browser page — the simpler the better.

Spotify API Setup

You’ll need a Spotify Developer account and an app set up at developer.spotify.com.

Make sure to set your Redirect URI properly, e.g. http://localhost:3000/callback.

Grab your:

SPOTIFY_CLIENT_ID=...
SPOTIFY_CLIENT_SECRET=...
SPOTIFY_REFRESH_TOKEN=...

I recommend using the refresh token method so your overlay doesn’t die after an hour.

To generate it, I followed this guide — fast and painless.


Backend Service

This is the brain of the operation.

Every 5–10 seconds (or via Spotify’s player events), we poll the current track info and emit it via WebSocket to all connected clients.

Sample express setup:

import express from 'express';
import { Server } from 'ws';
import { getCurrentPlayback } from './spotify';

const app = express();
const server = app.listen(3000);
const wss = new Server({ server });

setInterval(async () => {
  const data = await getCurrentPlayback();
  wss.clients.forEach(client => {
    if (client.readyState === 1) {
      client.send(JSON.stringify(data));
    }
  });
}, 5000);

The getCurrentPlayback() function calls Spotify's /v1/me/player/currently-playing endpoint using your refresh token flow.


Frontend Overlay

The overlay itself is just a tiny HTML page that connects to the WebSocket and updates the DOM accordingly.

<script>
  const socket = new WebSocket('ws://localhost:3000');
  socket.onmessage = (event) => {
    const data = JSON.parse(event.data);
    document.getElementById('track').innerText = `${data.name}${data.artist}`;
    document.getElementById('artwork').src = data.albumArt;
  };
</script>

And the structure:

<div id="overlay">
  <img id="artwork" />
  <div id="track">Loading...</div>
</div>

Styled with CSS — keep it minimal for best performance in OBS. I added some @keyframes for fade-in when tracks change.


OBS Integration

To add the overlay into OBS:

  1. Add a new Browser Source.
  2. Set the URL to http://localhost:3000/overlay.
  3. Match your canvas size (e.g. 800x200).
  4. Make sure Control audio via OBS is unchecked.

Done.

Every time the track changes, your OBS overlay updates automatically — no plugins, no hacks.


Final Result

This is what it looks like in action:


Summary

Was it overkill for a simple overlay? Maybe. But now I have:

  • A custom Node.js server I can reuse for more overlay logic.
  • Full creative control over how the UI looks.
  • Zero third-party dependencies (except Spotify itself).

Would I do it again? Absolutely.



The Full Code

#!/usr/bin/env node

import OBSWebSocket from 'obs-websocket-js';
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';

const exec = promisify(execCb);

// Config
const config = {
  obsUrl: 'ws://127.0.0.1:4455',
  obsPassword: 'JeQic9cGJ2DCOcRT',
  sources: [
    { name: 'Spotify', type: 'source', sceneName: 'overlay' },
    { name: 'Spotify Card', type: 'folder', sceneName: 'overlay' }
  ],
  reloadDelay: 500,
  cacheFile: './.spotify-current-song.json',
  checkInterval: 5000
};

// Spotify helpers
async function getCurrentSong() {
  try {
    const { stdout } = await exec('sp current');
    const lines = stdout.trim().split('\n');

    const info = Object.fromEntries(lines.map(line => {
      const [key, ...rest] = line.trim().split(/\s+/);
      return [key, rest.join(' ')];
    }));

    return {
      album: info.Album || '',
      artist: info.Artist || '',
      title: info.Title || '',
      albumArtist: info.AlbumArtist || ''
    };
  } catch (err) {
    console.error('Failed to get current song:', err.message);
    return null;
  }
}

async function isSpotifyPlaying() {
  try {
    const { stdout } = await exec('sp status');
    return stdout.trim() === 'Playing';
  } catch (err) {
    console.error('Failed to check Spotify status:', err.message);
    return false;
  }
}

// Cache helpers
async function readCachedSong() {
  try {
    const data = await fs.readFile(config.cacheFile, 'utf8');
    return JSON.parse(data);
  } catch {
    return {};
  }
}

async function writeCachedSong(info) {
  try {
    await fs.writeFile(config.cacheFile, JSON.stringify(info), 'utf8');
  } catch (err) {
    console.error('Failed to write cache file:', err.message);
  }
}

// OBS control
async function toggleSourceVisibility(obs, name, scene, visible) {
  const { sceneItems } = await obs.call('GetSceneItemList', { sceneName: scene });
  const item = sceneItems.find(i => i.sourceName === name);

  if (!item) {
    throw new Error(`Source "${name}" not found in scene "${scene}"`);
  }

  console.log(`Toggling "${name}" to ${visible ? 'visible' : 'hidden'}`);
  await obs.call('SetSceneItemEnabled', {
    sceneName: scene,
    sceneItemId: item.sceneItemId,
    sceneItemEnabled: visible
  });
}

async function reloadOBSSources(doReload = true) {
  const obs = new OBSWebSocket();

  try {
    console.log(`Connecting to OBS at ${config.obsUrl}...`);
    await obs.connect(config.obsUrl, config.obsPassword);
    console.log('Connected.');

    for (const source of config.sources) {
      await toggleSourceVisibility(obs, source.name, source.sceneName, false);
    }

    if (!doReload) return;

    console.log(`Waiting ${config.reloadDelay}ms...`);
    await new Promise(r => setTimeout(r, config.reloadDelay));

    for (const source of config.sources) {
      await toggleSourceVisibility(obs, source.name, source.sceneName, true);
    }

    console.log('OBS sources refreshed.');
  } catch (err) {
    console.error('OBS error:', err.message);
  } finally {
    await obs.disconnect().catch(() => {});
  }
}

// Song check loop
async function checkForSongChanges() {
  if (!(await isSpotifyPlaying())) {
    console.log('Spotify not playing. Skipping.');
    await reloadOBSSources(false);
    return;
  }

  const current = await getCurrentSong();
  if (!current) return;

  const cached = await readCachedSong();
  const hasChanged = ['title', 'artist', 'album'].some(key => current[key] !== cached[key]);

  if (hasChanged) {
    console.log(`Song changed: ${cached.artist || 'N/A'} - ${cached.title || 'N/A'}${current.artist} - ${current.title}`);
    await writeCachedSong(current);
    await reloadOBSSources();
  } else {
    console.log(`No change: ${current.artist} - ${current.title}`);
  }
}

// Entry points
async function runContinuous() {
  console.log(`Checking every ${config.checkInterval / 1000}s...`);
  await checkForSongChanges();
  setInterval(checkForSongChanges, config.checkInterval);
}

async function runOnce() {
  console.log('Single check...');
  await checkForSongChanges();
}

async function forceRefresh() {
  console.log('Forcing refresh...');
  await reloadOBSSources();
}

// CLI arg routing
const args = process.argv.slice(2);
if (args.includes('--once')) runOnce();
else if (args.includes('--force')) forceRefresh();
else runContinuous();


Tech Recap


See you next post 👋 And if you end up building this yourself, share it with me — I’d love to see what you make.

0
0
0
0