diff --git a/docs/superpowers/plans/2026-04-26-yt-music-player.md b/docs/superpowers/plans/2026-04-26-yt-music-player.md new file mode 100644 index 0000000..63e16e4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-yt-music-player.md @@ -0,0 +1,1033 @@ +# 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 }>} + */ +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: — <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.