Files
whats-that-music/docs/superpowers/plans/2026-04-26-yt-music-player.md
T
shadowdaoandClaude Opus 4.7 c9023a8da2 Add implementation plan for embedded YouTube Music player
Bite-sized, TDD-where-applicable tasks covering branch setup, state
merge unit tests, server module, preload scraper, Electron main
rewrite, tray rewrite, manual smoke tests, Windows zip build, and
cleanup of the superseded extension/server/OS-detection code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:24:17 -07:00

31 KiB

Embedded YouTube Music Player Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the OS-detection / browser-extension architecture with a single Electron app that embeds music.youtube.com, scrapes now-playing state from inside the embedded view via a preload script, and serves it on localhost:9095 for OBS / overlay clients.

Architecture: One Electron process. Main process runs an in-process Express + WebSocket server. A BrowserWindow loads YouTube Music with a preload script that polls the DOM and pushes state to the main process via IPC. The main process forwards state to the server, which broadcasts on WS. Overlay (overlay/index.html) is served by the same in-process server.

Tech Stack: Electron 33, Node.js (built-in node:test for unit tests), Express 5, ws, electron-builder (Windows zip target only).

Spec: docs/superpowers/specs/2026-04-26-yt-music-player-design.md


File Structure

Files created:

  • desktop-client/state.js — pure mergeState(current, update) and initialState. No deps. Unit-tested.
  • desktop-client/server.js — startServer({ port, overlayDir }) → Promise<{ setState, stop }>. In-process Express + WS.
  • desktop-client/yt-scraper.js — Electron preload script. Scrapes the YT Music DOM and sends wtm:state IPC messages.
  • desktop-client/tests/state.test.js — unit tests for mergeState.

Files modified:

  • desktop-client/index.js — replaced wholesale: starts server, opens YT Music BrowserWindow, wires IPC, manages lifecycle.
  • desktop-client/tray.js — rewritten: simpler menu, no URL config.
  • desktop-client/package.json — adds express, ws; adds test script; switches build.win.target to zip; bundles ../overlay.
  • README.md — rewritten to describe the single-app model.

Files deleted:

  • extension/ (entire directory)
  • desktop-client/backends/ (entire directory)
  • desktop-client/config.js
  • server/ (entire directory)
  • package.json (root)
  • package-lock.json (root)
  • node_modules/ (root)

Files unchanged:

  • overlay/index.html — served as-is by the new in-process server.

Branching context

The current branch is feature/os-media-detection. The spec proposes renaming it to feature/embedded-yt-music-player and abandoning the OS-detection direction. The original spec wording said "cut from main and cherry-pick the spec," but rename is simpler and equivalent in outcome (the OS-detection code is deleted as part of this plan anyway). Task 1 does the rename. The committed history of the old branch is preserved in git.


Task 1: Set up branch

Files:

  • None (git operations only).

  • Step 1: Verify current state

Run:

git status --short
git branch --show-current

Expected: branch feature/os-media-detection. Working tree may have M server/index.js (the previous-direction WIP) and untracked desktop-client/. Both are expected.

  • Step 2: Stash the OS-detection WIP modification to server/index.js

The modified server/index.js is in-progress work for the abandoned direction. Stash it so it isn't lost (it stays accessible via git stash list) and is out of the way for the rename.

git stash push -m "WIP: feature/os-media-detection server/index.js" -- server/index.js
git status --short

Expected: M server/index.js is gone; untracked desktop-client/ and docs/ stay.

  • Step 3: Rename the branch
git branch -m feature/embedded-yt-music-player
git branch --show-current

Expected: feature/embedded-yt-music-player.

  • Step 4: Commit

No commit needed for this task. Branch renames don't produce commits.


Task 2: Add new dependencies and test script to desktop-client/package.json

Files:

  • Modify: desktop-client/package.json

  • Step 1: Inspect current package.json

Run:

cat desktop-client/package.json

Expected: shows the existing deps (electron-store) and devDeps (electron, electron-builder).

  • Step 2: Update package.json

Replace desktop-client/package.json with:

{
  "name": "whats-that-music-client",
  "version": "1.0.0",
  "description": "Single-app YouTube Music player + now-playing overlay server for OBS",
  "main": "index.js",
  "scripts": {
    "start": "electron .",
    "test": "node --test tests",
    "build": "electron-builder"
  },
  "build": {
    "appId": "com.whatsthatmusic.client",
    "productName": "WhatsThatMusic",
    "files": [
      "**/*",
      "!tests/**",
      "../overlay/**/*"
    ],
    "extraMetadata": {
      "main": "index.js"
    },
    "win": {
      "target": "zip"
    }
  },
  "dependencies": {
    "electron-store": "^8.1.0",
    "express": "^5.2.1",
    "ws": "^8.20.0"
  },
  "devDependencies": {
    "electron": "^33.0.0",
    "electron-builder": "^25.0.0"
  }
}

Notes:

  • npm test runs from inside desktop-client/ (npm scripts cd into the package root), so node --test tests resolves to desktop-client/tests/.

  • productName: "WhatsThatMusic" (no apostrophe/spaces) gives a clean WhatsThatMusic.exe filename in the zip.

  • electron-store is kept here for now; it's used by no remaining code after Task 7, but pruning it is part of Task 11.

  • Step 3: Install the new deps

cd desktop-client && npm install && cd ..

Expected: node_modules/express and node_modules/ws exist under desktop-client/. No errors.

  • Step 4: Verify tests can be discovered (no tests yet, should be a clean no-op)
cd desktop-client && npm test; cd ..

Expected: command exits cleanly (no tests yet — node --test on an empty/nonexistent dir reports 0 tests).

  • Step 5: Commit
git add desktop-client/package.json desktop-client/package-lock.json
git commit -m "Add server deps and test script to desktop-client package"

Task 3: Pure state-merge module with unit tests (TDD)

Files:

  • Create: desktop-client/state.js

  • Create: desktop-client/tests/state.test.js

  • Step 1: Write the failing tests first

Create desktop-client/tests/state.test.js:

const test = require('node:test');
const assert = require('node:assert/strict');
const { initialState, mergeState } = require('../state');

test('initialState has the expected shape', () => {
  assert.deepEqual(initialState, {
    title: '',
    artist: '',
    album: '',
    albumArt: '',
    isPlaying: false,
    progress: 0,
    duration: 0,
  });
});

test('mergeState applies a single field update', () => {
  const next = mergeState(initialState, { title: 'Foo' });
  assert.equal(next.title, 'Foo');
});

test('mergeState preserves fields not present in the update', () => {
  const current = { ...initialState, title: 'Old', artist: 'Bar' };
  const next = mergeState(current, { title: 'New' });
  assert.equal(next.artist, 'Bar');
});

test('mergeState treats undefined fields as no-change', () => {
  const current = { ...initialState, isPlaying: true };
  const next = mergeState(current, { isPlaying: undefined });
  assert.equal(next.isPlaying, true);
});

test('mergeState applies false as a real value (not a no-change)', () => {
  const current = { ...initialState, isPlaying: true };
  const next = mergeState(current, { isPlaying: false });
  assert.equal(next.isPlaying, false);
});

test('mergeState does not mutate inputs', () => {
  const current = { ...initialState };
  const update = { title: 'X' };
  const before = { ...current };
  mergeState(current, update);
  assert.deepEqual(current, before);
  assert.deepEqual(update, { title: 'X' });
});
  • Step 2: Run tests to verify they fail

Run:

cd desktop-client && npm test; cd ..

Expected: all 6 tests fail with Cannot find module '../state'.

  • Step 3: Implement state.js

Create desktop-client/state.js:

const initialState = Object.freeze({
  title: '',
  artist: '',
  album: '',
  albumArt: '',
  isPlaying: false,
  progress: 0,
  duration: 0,
});

function mergeState(current, update) {
  return {
    title: update.title ?? current.title,
    artist: update.artist ?? current.artist,
    album: update.album ?? current.album,
    albumArt: update.albumArt ?? current.albumArt,
    isPlaying: update.isPlaying ?? current.isPlaying,
    progress: update.progress ?? current.progress,
    duration: update.duration ?? current.duration,
  };
}

module.exports = { initialState, mergeState };

The ?? operator (nullish coalescing) is what makes the false-vs-undefined distinction work: only null and undefined fall through to the current value; false, 0, and '' all replace it.

  • Step 4: Run tests to verify they pass
cd desktop-client && npm test; cd ..

Expected: all 6 tests pass.

  • Step 5: Commit
git add desktop-client/state.js desktop-client/tests/state.test.js
git commit -m "Add pure state-merge module with unit tests"

Task 4: In-process server module

Files:

  • Create: desktop-client/server.js

This task lifts the existing server/index.js into a reusable module, removes the POST /api/now-playing route (no external producers in the new design), and uses mergeState from Task 3.

  • Step 1: Create desktop-client/server.js
const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');
const { initialState, mergeState } = require('./state');

/**
 * Start the now-playing server.
 *
 * @param {Object} opts
 * @param {number} opts.port         Port to listen on (e.g. 9095).
 * @param {string} [opts.overlayDir] Absolute path to the overlay/ directory to serve as static files. Optional (omit for tests).
 * @returns {Promise<{ setState: (update: object) => object, stop: () => Promise<void> }>}
 */
function startServer({ port, overlayDir }) {
  let currentState = { ...initialState };

  const app = express();
  const server = http.createServer(app);
  const wss = new WebSocketServer({ server });

  // CORS: overlay/OBS browser sources may be loaded from arbitrary origins.
  app.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
    res.header('Access-Control-Allow-Headers', 'Content-Type');
    if (req.method === 'OPTIONS') return res.sendStatus(204);
    next();
  });

  app.get('/api/health', (req, res) => res.json({ status: 'ok' }));

  app.get('/api/now-playing', (req, res) => res.json(currentState));

  // Image proxy so overlay clients can fetch album art that may have CORS / referrer restrictions.
  app.get('/api/image-proxy', async (req, res) => {
    const url = req.query.url;
    if (!url) return res.status(400).send('Missing url parameter');
    try {
      const response = await fetch(url);
      if (!response.ok) return res.status(response.status).send('Upstream error');
      res.set('Content-Type', response.headers.get('content-type') || 'image/jpeg');
      res.set('Cache-Control', 'public, max-age=3600');
      const buffer = await response.arrayBuffer();
      res.send(Buffer.from(buffer));
    } catch (err) {
      res.status(502).send('Failed to fetch image');
    }
  });

  if (overlayDir) {
    app.use(express.static(overlayDir));
  }

  wss.on('connection', (ws) => {
    ws.send(JSON.stringify(currentState));
  });

  function setState(update) {
    currentState = mergeState(currentState, update || {});
    const message = JSON.stringify(currentState);
    wss.clients.forEach((client) => {
      if (client.readyState === 1) client.send(message);
    });
    return currentState;
  }

  function stop() {
    return new Promise((resolve) => {
      wss.close(() => server.close(() => resolve()));
    });
  }

  return new Promise((resolve, reject) => {
    server.listen(port, '127.0.0.1', () => {
      console.log(`[wtm] server listening on http://127.0.0.1:${port}`);
      resolve({ setState, stop });
    });
    server.on('error', reject);
  });
}

module.exports = { startServer };
  • Step 2: Manual smoke test of the server module standalone

Create a temporary file desktop-client/_smoke.js with:

const path = require('path');
const { startServer } = require('./server');

(async () => {
  const handle = await startServer({
    port: 9095,
    overlayDir: path.join(__dirname, '..', 'overlay'),
  });
  console.log('Server up. Visit http://127.0.0.1:9095/ for the overlay.');
  // Push a fake state after 1s so a connected overlay shows something.
  setTimeout(() => handle.setState({ title: 'Smoke Test', artist: 'Tester', isPlaying: true }), 1000);
})();

Run it:

cd desktop-client && node _smoke.js

In another shell, verify endpoints:

curl -s http://127.0.0.1:9095/api/health
curl -s http://127.0.0.1:9095/api/now-playing

Expected:

  • /api/health → {"status":"ok"}
  • /api/now-playing → JSON state object with title: "Smoke Test" after the 1s delay
  • Visiting http://127.0.0.1:9095/ in a browser shows the overlay rendering "Smoke Test — Tester"

Stop the smoke server with Ctrl+C.

  • Step 3: Delete the smoke test file
rm desktop-client/_smoke.js
  • Step 4: Commit
git add desktop-client/server.js
git commit -m "Add in-process server module with setState entry point"

Task 5: Preload script — YT Music DOM scraper

Files:

  • Create: desktop-client/yt-scraper.js

This is the existing extension/content.js adapted to:

  • Use ipcRenderer.send('wtm:state', ...) instead of fetch(POST).

  • Drop the chrome.storage server-URL config (no URL — IPC is in-process).

  • Run as an Electron preload (has access to both DOM and ipcRenderer).

  • Step 1: Create desktop-client/yt-scraper.js

const { ipcRenderer } = require('electron');

const POLL_INTERVAL_MS = 3000;
let lastSent = null;

function scrapeNowPlaying() {
  const titleEl =
    document.querySelector('ytmusic-player-bar .title') ||
    document.querySelector('.ytmusic-player-bar .title');
  const title = titleEl ? titleEl.textContent.trim() : null;

  const bylineEl =
    document.querySelector('ytmusic-player-bar .byline') ||
    document.querySelector('.ytmusic-player-bar .byline');
  const artist = bylineEl ? bylineEl.textContent.trim() : null;

  const artImg =
    document.querySelector('ytmusic-player-bar .middle-controls .thumbnail-image-wrapper img') ||
    document.querySelector('ytmusic-player-bar .image img') ||
    document.querySelector('ytmusic-player-bar img.image');
  const albumArt = artImg ? artImg.src : null;

  // The play/pause button's title indicates state: when music is playing the
  // button's action is "Pause", and vice versa.
  const playPauseBtn = document.querySelector('#play-pause-button');
  let isPlaying = false;
  if (playPauseBtn) {
    const btnTitle = (
      playPauseBtn.getAttribute('title') ||
      playPauseBtn.getAttribute('aria-label') ||
      ''
    ).toLowerCase();
    isPlaying = btnTitle.includes('pause');
  }

  if (!title) return null;
  return { title, artist, albumArt, isPlaying };
}

function changed(a, b) {
  if (a === null && b === null) return false;
  if (a === null || b === null) return true;
  return (
    a.title !== b.title ||
    a.artist !== b.artist ||
    a.albumArt !== b.albumArt ||
    a.isPlaying !== b.isPlaying
  );
}

function poll() {
  const data = scrapeNowPlaying();
  if (!changed(data, lastSent)) return;
  lastSent = data;
  ipcRenderer.send('wtm:state', data || { isPlaying: false });
}

setInterval(poll, POLL_INTERVAL_MS);
poll();

console.log('[wtm] yt-scraper preload loaded');
  • Step 2: No tests at this step

The preload depends on a YT Music DOM and Electron's IPC; it can only be exercised by the integration smoke test in Task 9. No unit tests here.

  • Step 3: Commit
git add desktop-client/yt-scraper.js
git commit -m "Add YT Music preload scraper that emits wtm:state IPC"

Task 6: Rewrite Electron main entry (desktop-client/index.js)

Files:

  • Modify: desktop-client/index.js (full rewrite)

  • Step 1: Replace desktop-client/index.js

const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const { startServer } = require('./server');
const { createTray } = require('./tray');

const PORT = 9095;
const OVERLAY_DIR = path.join(__dirname, '..', 'overlay');
const YT_MUSIC_URL = 'https://music.youtube.com';

let serverHandle = null;
let mainWindow = null;
let tray = null;

async function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1280,
    height: 800,
    title: "What's That Music",
    autoHideMenuBar: true,
    webPreferences: {
      preload: path.join(__dirname, 'yt-scraper.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: false,
    },
  });

  mainWindow.on('closed', () => {
    mainWindow = null;
  });

  await mainWindow.loadURL(YT_MUSIC_URL);
}

app.whenReady().then(async () => {
  serverHandle = await startServer({ port: PORT, overlayDir: OVERLAY_DIR });

  ipcMain.on('wtm:state', (_event, payload) => {
    const next = serverHandle.setState(payload || { isPlaying: false });
    if (tray) tray.update({ title: next.title, artist: next.artist });
  });

  await createWindow();

  tray = createTray({
    port: PORT,
    onShow: () => {
      if (!mainWindow) return;
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.show();
      mainWindow.focus();
    },
    onQuit: () => app.quit(),
  });
});

app.on('window-all-closed', () => {
  app.quit();
});

app.on('before-quit', async (e) => {
  if (serverHandle) {
    e.preventDefault?.();
    try { await serverHandle.stop(); } catch (_) {}
    serverHandle = null;
    app.exit(0);
  }
});

Key choices, with their reasons:

  • sandbox: false — required for the preload to use require('electron') (ipcRenderer). With sandbox on, preloads can only use a tiny subset of the renderer API.

  • contextIsolation: true — preload runs in its own JS context, so it can't accidentally collide with YT Music's globals. The preload can still access document and ipcRenderer.

  • before-quit: stops the HTTP/WS server cleanly before the app exits, otherwise the port can stay bound briefly.

  • No tray-only mode: closing the only window quits the app (window-all-closed → app.quit()).

  • Step 2: Defer running it

We can't run this end-to-end yet — the new tray.js (Task 7) hasn't been written. The current tray.js references config.js (server-URL config) which we're removing. Run the integration smoke after Task 7.

  • Step 3: Commit
git add desktop-client/index.js
git commit -m "Rewrite Electron main: in-process server + YT Music BrowserWindow"

Task 7: Rewrite tray module

Files:

  • Modify: desktop-client/tray.js (full rewrite)

  • Step 1: Replace desktop-client/tray.js

const { Tray, Menu, nativeImage, shell, clipboard } = require('electron');

// Minimal green-circle tray icon, kept from the previous version for visual continuity.
const TRAY_ICON_BASE64 =
  'data:image/png;base64,' +
  'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAa' +
  'RJREFUWEftlj1OxDAQhe0TcARuABUNDQUF4gRIFJyAG9BQcQJOwA1oKGgoOAI3wP' +
  'vkWRnZjr1xsgVilCiy4/n85s14nMGO/wY79v/dAfwH4T8IpUNoSj9FxAPiS+IHRO' +
  'nzEscvUz4gnkV8JF5C7BNfEe8j6pznxFvEp8RviM8RfyXOlxPVd0j5QAgOBaET5E' +
  'xgXxBPIS5GzlMEMxIJ3kLwPPFT4gPEudC+ILYimlkuJKovoeSBaEuCg8CTiHPC0r' +
  'sQnETM7Q7HqRdS70ygIHYb4p/Ee4hziT0cDx23BaGMwA3EF4nXEi8iz6fOF4Tae+' +
  'LniOuJsxN5PxZ+JgTfIL5K7E4cJIiScr5LjA7lPmvuP0/ETvAEvQn4Tqy+HNNyTk' +
  'fiWeT/0niDe4kqEkX5e5bOGwmhOsRsN2UEQ/qvY/ISxOuIq4k7tI2EcJ38NcT3iX' +
  'PB7S4/EuoISgYrLKD1dOhOPFP6+4R7ieoQPNv9/ULZ+zfis+0Y1e+F7oF3H5F+Xt' +
  'T5p0AqBYeEeEF8k/gtcS56z6f/E5UOLHL8A8a4yC0IHbH4AAAAASUVORK5CYII=';

let trayInstance = null;
let opts = null;
let currentTitle = null;
let currentArtist = null;

function buildMenu() {
  const url = `http://localhost:${opts.port}/`;
  const nowPlaying =
    currentTitle && currentArtist
      ? `${currentTitle} — ${currentArtist}`
      : currentTitle || 'Nothing playing';

  return Menu.buildFromTemplate([
    { label: "What's That Music", enabled: false },
    { label: `Now Playing: ${nowPlaying}`, enabled: false },
    { type: 'separator' },
    { label: 'Show Player', click: () => opts.onShow && opts.onShow() },
    { label: 'Open Overlay in Browser', click: () => shell.openExternal(url) },
    { label: 'Copy OBS URL', click: () => clipboard.writeText(url) },
    { type: 'separator' },
    { label: 'Quit', click: () => opts.onQuit && opts.onQuit() },
  ]);
}

/**
 * @param {Object} options
 * @param {number} options.port
 * @param {() => void} options.onShow
 * @param {() => void} options.onQuit
 * @returns {{ update: (info: { title: string|null, artist: string|null }) => void, destroy: () => void }}
 */
function createTray(options) {
  opts = options;

  const icon = nativeImage.createFromDataURL(TRAY_ICON_BASE64);
  icon.setTemplateImage(true);

  trayInstance = new Tray(icon);
  trayInstance.setToolTip("What's That Music");
  trayInstance.setContextMenu(buildMenu());

  return {
    update({ title, artist }) {
      currentTitle = title || null;
      currentArtist = artist || null;
      if (trayInstance) trayInstance.setContextMenu(buildMenu());
    },
    destroy() {
      if (trayInstance) {
        trayInstance.destroy();
        trayInstance = null;
      }
    },
  };
}

module.exports = { createTray };
  • Step 2: Commit
git add desktop-client/tray.js
git commit -m "Rewrite tray: simple menu, no server-URL config"

Task 8: Manual integration smoke test

Files:

  • None (manual testing only).

  • Step 1: Start the app

Run from the project root:

cd desktop-client && npm start

The Electron app should launch and open https://music.youtube.com. The terminal should print [wtm] server listening on http://127.0.0.1:9095.

  • Step 2: Sign in to YT Music in the embedded window

Use the embedded window to sign in to your Google account. Cookies persist across launches by default.

  • Step 3: Verify the preload is running

Open the Electron window's DevTools (View → Toggle Developer Tools, or Ctrl+Shift+I). In the Console, you should see [wtm] yt-scraper preload loaded.

  • Step 4: Open the overlay in a separate browser

In a different browser (not the Electron window), visit http://localhost:9095/. The overlay should load (initially empty / not visible).

  • Step 5: Play a track

Play any track in YT Music. Within ~3 s the overlay should appear and show the title and artist. Play/pause and skip should reflect in the overlay.

  • Step 6: Verify tray menu

Right-click the tray icon. Verify:

  • "Now Playing: