Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5b20498a | ||
|
|
e49078ee03 | ||
|
|
f73ea93dde |
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 };
|
||||
+1
-14
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user