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>
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— puremergeState(current, update)andinitialState. 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 sendswtm:stateIPC messages.desktop-client/tests/state.test.js— unit tests formergeState.
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— addsexpress,ws; addstestscript; switchesbuild.win.targettozip; bundles../overlay.README.md— rewritten to describe the single-app model.
Files deleted:
extension/(entire directory)desktop-client/backends/(entire directory)desktop-client/config.jsserver/(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 testruns from insidedesktop-client/(npm scripts cd into the package root), sonode --test testsresolves todesktop-client/tests/. -
productName: "WhatsThatMusic"(no apostrophe/spaces) gives a cleanWhatsThatMusic.exefilename in the zip. -
electron-storeis 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 withtitle: "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 offetch(POST). -
Drop the
chrome.storageserver-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 userequire('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 accessdocumentandipcRenderer. -
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: