diff --git a/desktop-client/index.js b/desktop-client/index.js index 62ca9e6..90ad297 100644 --- a/desktop-client/index.js +++ b/desktop-client/index.js @@ -67,8 +67,8 @@ app.whenReady().then(async () => { if (tray) tray.update({ title: next.title, artist: next.artist }); }); - await createWindow(); - + // Create the tray before the window: the scraper can report a track as soon as + // the page loads, and state arriving while `tray` is null would be dropped. tray = createTray({ port: serverHandle.port, onShow: () => { @@ -78,6 +78,9 @@ app.whenReady().then(async () => { }, onQuit: () => app.quit(), }); + + await createWindow(); + }).catch((err) => { dialog.showErrorBox('Failed to start', err && err.message ? err.message : String(err)); app.exit(1); diff --git a/desktop-client/now-playing.js b/desktop-client/now-playing.js new file mode 100644 index 0000000..8b5b856 --- /dev/null +++ b/desktop-client/now-playing.js @@ -0,0 +1,63 @@ +// Extracts the current track from a YouTube Music page. `doc` and `nav` are +// injected so this stays testable without a browser. + +function largestArtwork(artwork) { + if (!artwork || !artwork.length) return null; + const area = (a) => { + const [w, h] = String(a.sizes || '').split('x').map(Number); + return (w || 0) * (h || 0); + }; + return artwork.reduce((best, a) => (area(a) > area(best) ? a : best)).src || null; +} + +// Preferred source: YT Music publishes the track via the Media Session API +// even when its player-bar markup changes. +function fromMediaSession(nav) { + const session = nav && nav.mediaSession; + const meta = session && session.metadata; + if (!meta || !meta.title) return null; + return { + title: meta.title, + artist: meta.artist || null, + albumArt: largestArtwork(meta.artwork), + isPlaying: session.playbackState === 'playing', + }; +} + +// Fallback for older YT Music layouts that still render ytmusic-player-bar. +function fromPlayerBar(doc) { + const titleEl = + doc.querySelector('ytmusic-player-bar .title') || + doc.querySelector('.ytmusic-player-bar .title'); + const title = titleEl ? titleEl.textContent.trim() : null; + if (!title) return null; + + const bylineEl = + doc.querySelector('ytmusic-player-bar .byline') || + doc.querySelector('.ytmusic-player-bar .byline'); + const artImg = + doc.querySelector('ytmusic-player-bar .middle-controls .thumbnail-image-wrapper img') || + doc.querySelector('ytmusic-player-bar .image img') || + doc.querySelector('ytmusic-player-bar img.image'); + + // The button's title is the action it will perform: "Pause" while playing. + const btn = doc.querySelector('#play-pause-button'); + let isPlaying = false; + if (btn) { + const label = (btn.getAttribute('title') || btn.getAttribute('aria-label') || '').toLowerCase(); + isPlaying = label.includes('pause'); + } + + return { + title, + artist: bylineEl ? bylineEl.textContent.trim() : null, + albumArt: artImg ? artImg.src : null, + isPlaying, + }; +} + +function scrapeNowPlaying(doc, nav) { + return fromMediaSession(nav) || fromPlayerBar(doc); +} + +module.exports = { scrapeNowPlaying }; diff --git a/desktop-client/package-lock.json b/desktop-client/package-lock.json index b470816..ba63d9a 100644 --- a/desktop-client/package-lock.json +++ b/desktop-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "whats-that-music-client", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "whats-that-music-client", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "express": "^5.2.1", "ws": "^8.20.0" diff --git a/desktop-client/package.json b/desktop-client/package.json index 0156af2..7f42eed 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "whats-that-music-client", - "version": "1.0.0", + "version": "1.0.1", "description": "Single-app YouTube Music player + now-playing overlay server for OBS", "main": "index.js", "scripts": { diff --git a/desktop-client/server.js b/desktop-client/server.js index 0f0b3b8..3738e41 100644 --- a/desktop-client/server.js +++ b/desktop-client/server.js @@ -75,6 +75,9 @@ async function startServer({ port, overlayDir }) { function stop() { return new Promise((resolve) => { + // wss.close() does not drop existing connections and its callback waits + // for every client to disconnect, so a connected overlay would hang quit. + wss.clients.forEach((client) => client.terminate()); wss.close(() => { server.closeAllConnections(); server.close(() => resolve()); diff --git a/desktop-client/tests/now-playing.test.js b/desktop-client/tests/now-playing.test.js new file mode 100644 index 0000000..4cd679d --- /dev/null +++ b/desktop-client/tests/now-playing.test.js @@ -0,0 +1,60 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { scrapeNowPlaying } = require('../now-playing'); + +const emptyDoc = { querySelector: () => null }; + +function nav(metadata, playbackState) { + return { mediaSession: { metadata, playbackState } }; +} + +test('reads track info from the Media Session when the player bar is absent', () => { + const result = scrapeNowPlaying( + emptyDoc, + nav( + { + title: 'DOMINO', + artist: 'Stray Kids', + album: '', + artwork: [ + { src: 'small.jpg', sizes: '60x60' }, + { src: 'big.jpg', sizes: '544x544' }, + ], + }, + 'playing' + ) + ); + assert.deepStrictEqual(result, { + title: 'DOMINO', + artist: 'Stray Kids', + albumArt: 'big.jpg', + isPlaying: true, + }); +}); + +test('reports isPlaying false when the Media Session is paused', () => { + const result = scrapeNowPlaying(emptyDoc, nav({ title: 'T', artist: 'A', artwork: [] }, 'paused')); + assert.strictEqual(result.isPlaying, false); + assert.strictEqual(result.albumArt, null); +}); + +test('returns null when nothing is loaded anywhere', () => { + assert.strictEqual(scrapeNowPlaying(emptyDoc, nav(null, 'none')), null); + assert.strictEqual(scrapeNowPlaying(emptyDoc, {}), null); +}); + +test('falls back to the legacy player-bar DOM when Media Session has no metadata', () => { + const els = { + 'ytmusic-player-bar .title': { textContent: ' Old Song ' }, + 'ytmusic-player-bar .byline': { textContent: 'Old Artist' }, + 'ytmusic-player-bar img.image': { src: 'art.jpg' }, + '#play-pause-button': { getAttribute: (n) => (n === 'title' ? 'Pause' : null) }, + }; + const doc = { querySelector: (s) => els[s] || null }; + assert.deepStrictEqual(scrapeNowPlaying(doc, nav(null, 'none')), { + title: 'Old Song', + artist: 'Old Artist', + albumArt: 'art.jpg', + isPlaying: true, + }); +}); diff --git a/desktop-client/tests/server.test.js b/desktop-client/tests/server.test.js new file mode 100644 index 0000000..b3d5c36 --- /dev/null +++ b/desktop-client/tests/server.test.js @@ -0,0 +1,20 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const WebSocket = require('ws'); +const { startServer } = require('../server'); + +test('stop() resolves even while an overlay WebSocket client is connected', async () => { + const handle = await startServer({ port: 9300 }); + const client = new WebSocket(`ws://127.0.0.1:${handle.port}`); + client.on('error', () => {}); + await new Promise((resolve) => client.on('open', resolve)); + + let timer; + const result = await Promise.race([ + handle.stop().then(() => 'stopped'), + new Promise((resolve) => { timer = setTimeout(() => resolve('hung'), 2000); }), + ]); + clearTimeout(timer); + + assert.strictEqual(result, 'stopped'); +}); diff --git a/desktop-client/tests/tray-icon.test.js b/desktop-client/tests/tray-icon.test.js new file mode 100644 index 0000000..c47bd94 --- /dev/null +++ b/desktop-client/tests/tray-icon.test.js @@ -0,0 +1,25 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { TRAY_ICON_BASE64 } = require('../tray-icon'); + +test('tray icon is a structurally valid PNG (every chunk CRC checks out)', () => { + const prefix = 'data:image/png;base64,'; + assert.ok(TRAY_ICON_BASE64.startsWith(prefix)); + const png = Buffer.from(TRAY_ICON_BASE64.slice(prefix.length), 'base64'); + assert.strictEqual(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); + + const types = []; + let off = 8; + while (off < png.length) { + const len = png.readUInt32BE(off); + const type = png.subarray(off + 4, off + 8); + const body = png.subarray(off + 8, off + 8 + len); + const crc = png.readUInt32BE(off + 8 + len); + assert.strictEqual(zlib.crc32(Buffer.concat([type, body])), crc, `bad CRC in ${type}`); + types.push(type.toString()); + off += 12 + len; + } + assert.strictEqual(off, png.length, 'trailing bytes after last chunk'); + assert.deepStrictEqual([types[0], types.at(-1)], ['IHDR', 'IEND']); +}); diff --git a/desktop-client/tray-icon.js b/desktop-client/tray-icon.js new file mode 100644 index 0000000..f9c1e66 --- /dev/null +++ b/desktop-client/tray-icon.js @@ -0,0 +1,10 @@ +// 32x32 green circle (RGBA PNG) used for the tray icon. +const TRAY_ICON_BASE64 = + 'data:image/png;base64,' + + 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAt0lEQVR42u2XzQ2A' + + 'IAxGGYFJPLID47gDQ7BNF3AQhiBRDjUxJlZQSnvg8C4G8j1+hGKWbTWSmCnQ2MEV' + + 'QgEKqZCRhN8Ctuku4DFgrwSwTxeB2BB8J/4RsI2jpmbDfhHoEX6VaBKIHcPJ5Xja' + + 'cDsTvkYAGAXgTcAxhp84SiAMEAiUAAwQAEogDRBIlEAeIJBVC4gvgfgmFP8NxQ8i' + + '8aNYxWUkfh2rKEhUlGTiRamaslzFw2S+Ddk4AA0mvKa9jODdAAAAAElFTkSuQmCC'; + +module.exports = { TRAY_ICON_BASE64 }; diff --git a/desktop-client/tray.js b/desktop-client/tray.js index 034a18b..4e2e7b5 100644 --- a/desktop-client/tray.js +++ b/desktop-client/tray.js @@ -1,18 +1,5 @@ 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='; +const { TRAY_ICON_BASE64 } = require('./tray-icon'); /** * @param {Object} options diff --git a/desktop-client/yt-scraper.js b/desktop-client/yt-scraper.js index 4fa8a8c..6f22c6c 100644 --- a/desktop-client/yt-scraper.js +++ b/desktop-client/yt-scraper.js @@ -1,42 +1,9 @@ const { ipcRenderer } = require('electron'); +const { scrapeNowPlaying } = require('./now-playing'); 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; @@ -49,7 +16,7 @@ function changed(a, b) { } function poll() { - const data = scrapeNowPlaying(); + const data = scrapeNowPlaying(document, navigator); if (!changed(data, lastSent)) return; lastSent = data; ipcRenderer.send('wtm:state', data || { isPlaying: false });