1034 lines
31 KiB
Markdown
1034 lines
31 KiB
Markdown
# 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.
|