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