Author SHA1 Message Date
shadowdaoandClaude Sonnet 5.5 12cfca8b17 Publish a fixed-URL "latest" AppImage for static-URL updaters
Gitea has no /releases/latest/download/ redirect and release URLs always
include the tag, so updaters that poll a constant URL (Shelly) cannot use
them. After publishing a vX.Y.Z release, replace the asset on a rolling
pre-release tagged "latest":

  .../releases/download/latest/WhatsThatMusic-latest-linux-x86_64.AppImage

Replacing the asset changes its ETag and Last-Modified, which Shelly
compares. The pre-release flag keeps Gitea's own "latest release" on the
newest vX.Y.Z. The logic lives in .gitea/scripts/update-latest.sh so it can
be run against the API outside CI. The publish job now checks out first,
since checkout cleans the workspace and would delete downloaded artifacts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:17:46 -07:00
jknapp 0e5b20498a Merge pull request 'Fix now-playing scraper, quit hang, tray icon and tray race (v1.0.1)' (#6) from fix/now-playing-scraper-and-quit into main
Build release artifacts / linux (push) Successful in 4m9s
Build release artifacts / macos (push) Successful in 1m6s
Build release artifacts / windows (push) Successful in 1m51s
Build release artifacts / publish (push) Successful in 15s
2026-10-04 18:37:51 +00:00
shadowdaoandClaude Sonnet 5.5 e49078ee03 Fix now-playing scraper, quit hang, tray icon, and tray race (v1.0.1)
- Read the track from the Media Session API, falling back to the legacy
  ytmusic-player-bar scrape. Current YT Music layouts render no player bar,
  so the API and tray stayed empty while music played. Artist is also clean
  now (no "• views • likes" suffix).
- Terminate connected WebSocket clients before closing the server. ws's
  close() waits for every client to disconnect, so quitting with an overlay
  open hung forever, leaving the window playing and the port bound.
- Replace the tray icon with a valid PNG (the old one had a bad IDAT CRC and
  decoded to an empty image) and test that its chunks are well formed.
- Create the tray before the window so early state updates are not dropped.
- Bump version to 1.0.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:37:38 -07:00
jknapp f73ea93dde Merge pull request 'Surface API errors in the publish step' (#5) from ci/harden-publish into main 2026-08-29 03:39:57 +00:00
shadowdaoandClaude Opus 5 d218f4c6f3 Surface API errors in the publish step
The publish job has never run successfully. When it failed on the v1.0.0
tag it reported "SyntaxError: Unexpected end of JSON input" from piping an
empty curl body into JSON.parse, which said nothing about the actual cause
(an unset token). An opaque failure there costs a full three-platform build.

Routes every call through a helper that captures the HTTP status and prints
the response body on 4xx/5xx. Also switches artifact iteration to while-read
so filenames with spaces cannot split, and dumps the artifact tree when
nothing matches.

Helper verified against the live API: existing tag resolves to its release
id, a missing tag reports HTTP 404 with the body and yields an empty id so
the create path runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 20:39:45 -07:00
jknapp 1f79d7bcfe Merge pull request 'Match actual runner labels and use the injected token' (#4) from fix/runner-labels into main 2026-08-29 01:57:08 +00:00
13 changed files with 317 additions and 67 deletions
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Maintains a rolling "latest" release whose AppImage always has the same
# download URL, for updaters that poll a static URL (e.g. Shelly) instead of
# understanding version tags:
#
# <server>/<owner>/<repo>/releases/download/latest/WhatsThatMusic-latest-linux-x86_64.AppImage
#
# Gitea serves ETag and Last-Modified on release assets, and replacing the
# asset changes both, which is how those updaters detect a new build.
# Gitea has no GitHub-style /releases/latest/download/ redirect, hence this.
#
# Marked as a pre-release so Gitea's own "latest release" stays the newest vX.Y.Z.
#
# Required env: TOKEN, API (…/api/v1/repos/<owner>/<repo>), GITHUB_REF_NAME, GITHUB_SHA
# Optional env: ARTIFACTS_DIR (default: artifacts)
set -euo pipefail
: "${TOKEN:?TOKEN is required}" "${API:?API is required}"
: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" "${GITHUB_SHA:?GITHUB_SHA is required}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
LATEST_TAG="latest"
LATEST_NAME="WhatsThatMusic-latest-linux-x86_64.AppImage"
api() {
local method="$1" path="$2"; shift 2
local out code body
out=$(curl -s -w '\n%{http_code}' -X "$method" -H "Authorization: token $TOKEN" "$API$path" "$@")
code=$(printf '%s' "$out" | tail -n1)
body=$(printf '%s' "$out" | sed '$d')
if [ "$code" -ge 400 ]; then
echo "$method $path -> HTTP $code" >&2
echo "$body" >&2
return 1
fi
printf '%s' "$body"
}
appimage=$(find "$ARTIFACTS_DIR" -type f -name '*.AppImage' | sort | head -n1)
if [ -z "$appimage" ]; then
echo "No AppImage under $ARTIFACTS_DIR; cannot update the $LATEST_TAG release." >&2
exit 1
fi
body_json=$(node -e '
console.log(JSON.stringify({
body: "Rolling pointer to the newest AppImage, currently " + process.argv[1] + ". " +
"The download URL for this asset never changes; versioned downloads are on each vX.Y.Z release.",
}));' "$GITHUB_REF_NAME")
rolling_id=$(api GET "/releases/tags/$LATEST_TAG" 2>/dev/null \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
if [ -z "${rolling_id:-}" ]; then
echo "No '$LATEST_TAG' release yet; creating it."
create_json=$(node -e '
console.log(JSON.stringify({
tag_name: "latest", name: "Latest (rolling)", target_commitish: process.argv[1],
prerelease: true, body: JSON.parse(process.argv[2]).body,
}));' "$GITHUB_SHA" "$body_json")
rolling_id=$(api POST "/releases" -H "Content-Type: application/json" -d "$create_json" \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
else
api PATCH "/releases/$rolling_id" -H "Content-Type: application/json" -d "$body_json" >/dev/null
fi
# Replace the asset: the new upload gets a new ETag and Last-Modified.
old_id=$(api GET "/releases/$rolling_id/assets" \
| node -pe "((JSON.parse(require('fs').readFileSync(0,'utf8'))).find(a => a.name === process.argv[1]) || {}).id || ''" "$LATEST_NAME")
if [ -n "$old_id" ]; then
api DELETE "/releases/$rolling_id/assets/$old_id" >/dev/null
fi
echo "Uploading $LATEST_NAME from $(basename "$appimage") ($(du -h "$appimage" | cut -f1))"
api POST "/releases/$rolling_id/assets?name=$LATEST_NAME" -F "attachment=@$appimage" >/dev/null
echo "Updated '$LATEST_TAG' release ($rolling_id) to $GITHUB_REF_NAME"
+50 -13
View File
@@ -129,6 +129,9 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Checkout first: it cleans the workspace, which would delete downloaded artifacts.
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
path: artifacts path: artifacts
@@ -146,30 +149,64 @@ jobs:
exit 1 exit 1
fi fi
release_id=$(curl -sf -H "Authorization: token $TOKEN" \ # Report the HTTP status and body on failure. A bare `curl -sf | JSON.parse`
"$API/releases/tags/$GITHUB_REF_NAME" \ # dies with "Unexpected end of JSON input" and hides the real cause, which is
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true) # how the v1.0.0 run failed on a missing token.
api() {
local method="$1" path="$2"; shift 2
local out code
out=$(curl -s -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $TOKEN" "$API$path" "$@")
code=$(printf '%s' "$out" | tail -n1)
body=$(printf '%s' "$out" | sed '$d')
if [ "$code" -ge 400 ]; then
echo "$method $path -> HTTP $code" >&2
echo "$body" >&2
return 1
fi
printf '%s' "$body"
}
# Reuse the release if the tag already has one, otherwise create it.
release_id=$(api GET "/releases/tags/$GITHUB_REF_NAME" 2>/dev/null \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
if [ -z "${release_id:-}" ]; then if [ -z "${release_id:-}" ]; then
release_id=$(curl -sf -X POST -H "Authorization: token $TOKEN" \ echo "No release for $GITHUB_REF_NAME yet; creating it."
release_id=$(api POST "/releases" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \ -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
"$API/releases" \ | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
fi fi
if [ -z "${release_id:-}" ]; then
echo "Could not determine a release id." >&2
exit 1
fi
echo "Publishing to release $release_id"
# while-read rather than word-splitting a find, so spaces in names are safe.
found=0 found=0
for f in $(find artifacts -type f \( -name '*.AppImage' -o -name '*.zip' -o -name '*.dmg' -o -name '*.exe' \)); do while IFS= read -r f; do
name=$(basename "$f") name=$(basename "$f")
echo "Attaching $name" echo "Attaching $name ($(du -h "$f" | cut -f1))"
curl -sf -X POST -H "Authorization: token $TOKEN" \ api POST "/releases/$release_id/assets?name=$name" -F "attachment=@$f" >/dev/null
-F "attachment=@$f" \
"$API/releases/$release_id/assets?name=$name" -o /dev/null
found=$((found + 1)) found=$((found + 1))
done done < <(find artifacts -type f \( -name '*.AppImage' -o -name '*.zip' \
-o -name '*.dmg' -o -name '*.exe' \) | sort)
if [ "$found" -eq 0 ]; then if [ "$found" -eq 0 ]; then
echo "No artifacts found to attach." >&2 echo "No artifacts found to attach; build jobs produced nothing." >&2
find artifacts -type f | head -20 >&2
exit 1 exit 1
fi fi
echo "Attached $found artifacts to release $release_id" echo "Attached $found artifacts to release $release_id"
# Constant-URL copy of the AppImage for updaters that poll a static URL
# (e.g. Shelly). Runs after the versioned release exists, so a failure here
# cannot leave a tag without its assets.
- name: Update rolling "latest" AppImage
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: bash .gitea/scripts/update-latest.sh
+5 -2
View File
@@ -67,8 +67,8 @@ app.whenReady().then(async () => {
if (tray) tray.update({ title: next.title, artist: next.artist }); 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({ tray = createTray({
port: serverHandle.port, port: serverHandle.port,
onShow: () => { onShow: () => {
@@ -78,6 +78,9 @@ app.whenReady().then(async () => {
}, },
onQuit: () => app.quit(), onQuit: () => app.quit(),
}); });
await createWindow();
}).catch((err) => { }).catch((err) => {
dialog.showErrorBox('Failed to start', err && err.message ? err.message : String(err)); dialog.showErrorBox('Failed to start', err && err.message ? err.message : String(err));
app.exit(1); app.exit(1);
+63
View File
@@ -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 };
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "whats-that-music-client", "name": "whats-that-music-client",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "whats-that-music-client", "name": "whats-that-music-client",
"version": "1.0.0", "version": "1.0.1",
"dependencies": { "dependencies": {
"express": "^5.2.1", "express": "^5.2.1",
"ws": "^8.20.0" "ws": "^8.20.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "whats-that-music-client", "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", "description": "Single-app YouTube Music player + now-playing overlay server for OBS",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+3
View File
@@ -75,6 +75,9 @@ async function startServer({ port, overlayDir }) {
function stop() { function stop() {
return new Promise((resolve) => { 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(() => { wss.close(() => {
server.closeAllConnections(); server.closeAllConnections();
server.close(() => resolve()); server.close(() => resolve());
+60
View File
@@ -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,
});
});
+20
View File
@@ -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');
});
+25
View File
@@ -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']);
});
+10
View File
@@ -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
View File
@@ -1,18 +1,5 @@
const { Tray, Menu, nativeImage, shell, clipboard } = require('electron'); const { Tray, Menu, nativeImage, shell, clipboard } = require('electron');
const { TRAY_ICON_BASE64 } = require('./tray-icon');
// 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=';
/** /**
* @param {Object} options * @param {Object} options
+2 -35
View File
@@ -1,42 +1,9 @@
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
const { scrapeNowPlaying } = require('./now-playing');
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
let lastSent = null; 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) { function changed(a, b) {
if (a === null && b === null) return false; if (a === null && b === null) return false;
if (a === null || b === null) return true; if (a === null || b === null) return true;
@@ -49,7 +16,7 @@ function changed(a, b) {
} }
function poll() { function poll() {
const data = scrapeNowPlaying(); const data = scrapeNowPlaying(document, navigator);
if (!changed(data, lastSent)) return; if (!changed(data, lastSent)) return;
lastSent = data; lastSent = data;
ipcRenderer.send('wtm:state', data || { isPlaying: false }); ipcRenderer.send('wtm:state', data || { isPlaying: false });