Files

1034 lines
31 KiB
Markdown
Raw Permalink Normal View History

# 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.