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

1034 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../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:
```bash
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.
```bash
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**
```bash
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:
```bash
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:
```json
{
"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**
```bash
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)**
```bash
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**
```bash
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`:
```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:
```bash
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`:
```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**
```bash
cd desktop-client && npm test; cd ..
```
Expected: all 6 tests pass.
- [ ] **Step 5: Commit**
```bash
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`**
```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:
```js
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:
```bash
cd desktop-client && node _smoke.js
```
In another shell, verify endpoints:
```bash
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**
```bash
rm desktop-client/_smoke.js
```
- [ ] **Step 4: Commit**
```bash
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`**
```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**
```bash
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`**
```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**
```bash
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`**
```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**
```bash
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:
```bash
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: <title> — <artist>" reflects the current track.
- "Show Player" focuses the YT Music window.
- "Open Overlay in Browser" opens `http://localhost:9095/` in the default browser.
- "Copy OBS URL" copies that URL (paste it somewhere to confirm).
- "Quit" exits the app cleanly.
- [ ] **Step 7: Confirm clean shutdown**
After Quit, run:
```bash
ss -tlnp 2>/dev/null | grep 9095 || echo "Port released"
```
Expected: "Port released" — the server module's `stop()` correctly released port 9095 in the `before-quit` handler.
- [ ] **Step 8: No commit**
This is verification only.
---
### Task 9: Configure Windows zip build target
**Files:**
- None to create (already configured in Task 2).
- This task is the verification step for the build config.
- [ ] **Step 1: Build the Windows zip**
Run from the project root:
```bash
cd desktop-client && npm run build -- --win zip --x64
```
Expected: `electron-builder` downloads the Windows Electron binaries on first run, then writes a zip to `desktop-client/dist/`. No Wine prompts. No code-signing errors (only warnings about missing cert, which are fine).
- [ ] **Step 2: Inspect the produced zip**
```bash
ls -la desktop-client/dist/
unzip -l desktop-client/dist/WhatsThatMusic-*.zip | head -50
```
Expected:
- `WhatsThatMusic.exe` is present at the top level of the zip.
- `resources/app/` (or similar) contains `index.js`, `server.js`, `state.js`, `tray.js`, `yt-scraper.js`, `package.json`, the bundled `node_modules`.
- The `overlay/` directory is present in `resources/app/` (verifying `build.files: "../overlay/**/*"` worked).
- `tests/` directory is NOT present (verifying the `!tests/**` exclusion).
- [ ] **Step 3: If overlay isn't bundled, fix the build.files entry**
If `overlay/` is missing from the zip, the `../overlay/**/*` glob may not resolve as expected when `electron-builder` runs from `desktop-client/`. Try replacing in `desktop-client/package.json`:
```json
"files": [
"**/*",
"!tests/**"
],
"extraResources": [
{ "from": "../overlay", "to": "overlay" }
]
```
Then update `OVERLAY_DIR` in `desktop-client/index.js` to fall back to the `extraResources` path when packaged:
```js
const OVERLAY_DIR = app.isPackaged
? path.join(process.resourcesPath, 'overlay')
: path.join(__dirname, '..', 'overlay');
```
(This is a fallback only — try the simpler `build.files` approach first.)
- [ ] **Step 4: Commit any build-config tweaks**
```bash
git add desktop-client/package.json desktop-client/index.js
git commit -m "Bundle overlay/ into Windows zip build"
```
(Skip if no changes were needed.)
---
### Task 10: Manual Windows-zip smoke test
**Files:**
- None (manual testing on a Windows machine).
This is a real-world verification. Do it before declaring the project shippable.
- [ ] **Step 1: Copy the zip to a Windows machine**
The zip is in `desktop-client/dist/WhatsThatMusic-*.zip`. Copy it via whatever channel works (network share, USB, scp, etc.).
- [ ] **Step 2: Unzip and run**
On Windows: extract the zip somewhere, double-click `WhatsThatMusic.exe`. SmartScreen will likely prompt — click "More info → Run anyway."
- [ ] **Step 3: Repeat the integration checks**
Repeat Task 8 steps 2–7 on the Windows machine: sign in to YT Music, play a track, open the overlay in another browser, verify tray menu items, quit cleanly.
- [ ] **Step 4: No commit**
Verification only.
---
### Task 11: Delete superseded files
**Files:**
- Delete: `extension/`, `desktop-client/backends/`, `desktop-client/config.js`, `server/`, root `package.json`, root `package-lock.json`, root `node_modules/`.
- [ ] **Step 1: Verify nothing in the new code references any of these**
Run from project root:
```bash
grep -rn "require.*config" desktop-client/ --include="*.js"
grep -rn "require.*backends" desktop-client/ --include="*.js"
grep -rn "require.*\.\./server" desktop-client/ --include="*.js"
```
Expected: no matches in any of the new code (`index.js`, `server.js`, `state.js`, `tray.js`, `yt-scraper.js`).
- [ ] **Step 2: Delete the old directories and files**
```bash
rm -rf extension
rm -rf desktop-client/backends
rm -f desktop-client/config.js
rm -rf server
rm -f package.json package-lock.json
rm -rf node_modules
```
- [ ] **Step 3: Drop unused `electron-store` dep (optional but YAGNI-clean)**
The new code does not use `electron-store`. Edit `desktop-client/package.json` and remove `"electron-store": "^8.1.0"` from `dependencies`. Then:
```bash
cd desktop-client && npm install && cd ..
```
- [ ] **Step 4: Re-run tests and re-launch to confirm nothing broke**
```bash
cd desktop-client && npm test && cd ..
cd desktop-client && npm start
# manually verify the app still launches and a track shows up in the overlay
# Ctrl+C / Quit when satisfied
```
Expected: tests still pass; the app still works.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "Delete extension, OS backends, standalone server, root npm project"
```
---
### Task 12: Update README
**Files:**
- Modify: `README.md` (full rewrite — the existing content is for the old architecture).
- [ ] **Step 1: Replace `README.md`**
```markdown
# What's That Music
A desktop app that plays YouTube Music and serves a now-playing overlay for OBS.
The app is a single Electron window that loads `music.youtube.com`. It also runs a small HTTP/WebSocket server on `localhost:9095` that streams the current track to any connected overlay client. Point OBS (or any browser) at `http://localhost:9095/` to see the overlay.
## Run from source
```
cd desktop-client
npm install
npm start
```
The app opens. Sign in to YouTube Music. Play a track. Visit `http://localhost:9095/` in another browser to see the overlay.
## Build a Windows zip
```
cd desktop-client
npm run build -- --win zip --x64
```
Output: `desktop-client/dist/WhatsThatMusic-<version>-win.zip`. Unzip on a Windows machine and run `WhatsThatMusic.exe`. The app is unsigned, so SmartScreen will warn on first launch — click "More info → Run anyway."
## OBS setup
1. Add a *Browser Source*.
2. URL: `http://localhost:9095/`.
3. Width / height: match your scene (the overlay positions itself absolutely; 1920x1080 is fine).
4. Make sure the app is running before OBS loads the source.
## Tests
```
cd desktop-client
npm test
```
Runs the `node:test` unit suite (`desktop-client/tests/`).
## Layout
- `desktop-client/` — the Electron app (main entry, server module, preload scraper, tray).
- `overlay/` — static HTML + JS the bundled server serves at `/`.
- `docs/superpowers/` — design and plan documents.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "Update README for single-app architecture"
```
---
### Task 13: Final verification + push
**Files:**
- None.
- [ ] **Step 1: One last clean run**
```bash
cd desktop-client && npm test && cd ..
cd desktop-client && npm start
# play a track, confirm overlay updates, quit
```
- [ ] **Step 2: Confirm the working tree is clean**
```bash
git status --short
```
Expected: empty output. No stray files.
- [ ] **Step 3: View the branch's commit history**
```bash
git log --oneline
```
Expected: a clean sequence of commits from this plan, all on `feature/embedded-yt-music-player`.
- [ ] **Step 4: Decide on push / merge**
Push the branch if you want it on the remote, or merge to `main` if you want it as the default. (Out of scope for this plan — handled by the user manually.)
---
## Plan summary
| Task | What it produces |
|------|------------------|
| 1 | Branch renamed; WIP stashed |
| 2 | Server deps + test script in `desktop-client/package.json` |
| 3 | Pure `mergeState` module + 6 unit tests (passing) |
| 4 | In-process server module |
| 5 | Preload scraper |
| 6 | New Electron main entry |
| 7 | Simplified tray |
| 8 | Manual integration smoke (dev mode) |
| 9 | Windows `.zip` build artifact |
| 10 | Manual integration smoke (built zip on Windows) |
| 11 | Old code deleted |
| 12 | README rewritten |
| 13 | Final verification |
The first shippable artifact is at the end of Task 9 (a Windows `.zip` that should work). Task 10 is the truthful "actually verified on Windows" gate. Tasks 11–13 are post-ship cleanup.