9 Commits
Author SHA1 Message Date
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
shadowdaoandClaude Opus 5 394dc7107c Match actual runner labels and use the injected token
The mac job targeted macos-arm64, but the registered Mac runner is
labelled macos-latest, so that job would have queued forever rather than
failing -- a job with no matching runner is silently pending.

Also switches the publish step to secrets.GITEA_TOKEN, which Gitea injects
automatically and scopes to the repo, instead of requiring a hand-created
RELEASE_TOKEN. RELEASE_TOKEN still overrides it if wider rights are needed.

Runners confirmed via /api/v1/admin/actions/runners: ubuntu-latest,
windows-latest (x2) and macos-latest are all online.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:56:58 -07:00
jknapp f8d10d3a24 Merge pull request 'Build each platform on its own native runner' (#3) from ci/native-builders into main 2026-08-29 01:53:39 +00:00
shadowdaoandClaude Opus 5 72485d44ff Build each platform on its own native runner
Splits the release workflow into linux, windows and macos jobs feeding a
single publish job. The publish step is separate so parallel builds cannot
race to create the release and fail on a duplicate tag.

Windows now builds natively, so signAndEditExecutable no longer has to be
disabled. That flag was suppressing rcedit, which is what embeds the icon
and version resources -- the v1.0.0 exe shipped with the stock Electron
icon as a result. Also adds an NSIS installer alongside the portable zip.

macOS gains a dmg target, which cannot be produced off macOS. Builds stay
unsigned pending a Developer ID, with CSC_IDENTITY_AUTO_DISCOVERY disabled
so electron-builder builds unsigned instead of failing on a real Mac.

Nothing cross-builds now; all three runners must be registered before
tagging, since a job with no matching runner queues silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:53:24 -07:00
jknapp 240535506b Merge pull request 'Add macOS target and platform-qualified artifact names' (#2) from release/v1.0.0 into main
Build AppImage / appimage (push) Failing after 22s
2026-08-29 01:11:44 +00:00
12 changed files with 362 additions and 88 deletions
+157 -29
View File
@@ -1,8 +1,21 @@
name: Build AppImage
name: Build release artifacts
# Builds the Linux AppImage on a version tag and attaches it to a Gitea release.
# Tag must match the version in desktop-client/package.json, since electron-builder
# names the artifact from package.json (not from the tag).
# Builds each platform natively on a v* tag and publishes one Gitea release.
#
# Runners required. None are registered yet, and a job with no matching runner
# queues silently rather than failing, so register all three before tagging:
# ubuntu-latest Linux AppImage
# windows-latest Windows NSIS installer + portable zip
# macos-latest macOS dmg + zip, both architectures (M-series builds both)
#
# Nothing cross-builds any more: dmg requires macOS, and the Windows exe needs
# rcedit on Windows to get its icon and version resources.
#
# A RELEASE_TOKEN secret is optional; GITEA_TOKEN is used by default.
#
# All output is unsigned. Once certificates exist: for macOS drop mac.identity
# from package.json and remove CSC_IDENTITY_AUTO_DISCOVERY; for Windows add the
# cert secrets. Until then users hit Gatekeeper and SmartScreen warnings.
on:
push:
tags:
@@ -10,18 +23,19 @@ on:
workflow_dispatch:
jobs:
appimage:
linux:
runs-on: ubuntu-latest
defaults:
run:
working-directory: desktop-client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
# Runs only here rather than in all three jobs: it is a repo-wide check,
# and this job uses bash, which is not guaranteed on the Windows runner.
- name: Verify tag matches package.json version
if: startsWith(github.ref, 'refs/tags/v')
run: |
@@ -29,7 +43,7 @@ jobs:
tag_version="${GITHUB_REF_NAME#v}"
if [ "$pkg_version" != "$tag_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match package.json version $pkg_version." >&2
echo "Bump package.json before tagging, or the AppImage will be misnamed." >&2
echo "Bump package.json before tagging, or artifacts will be misnamed." >&2
exit 1
fi
@@ -42,31 +56,145 @@ jobs:
- name: Build AppImage
run: npx electron-builder --linux AppImage --publish never
- name: Attach AppImage to release
if: startsWith(github.ref, 'refs/tags/v')
- uses: actions/upload-artifact@v3
with:
name: linux
path: desktop-client/dist/*.AppImage
if-no-files-found: error
windows:
runs-on: windows-latest
defaults:
run:
working-directory: desktop-client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build installer and portable zip
run: npx electron-builder --win nsis zip --publish never
- uses: actions/upload-artifact@v3
with:
name: windows
path: |
desktop-client/dist/*.exe
desktop-client/dist/*-win-*.zip
if-no-files-found: error
macos:
runs-on: macos-latest
defaults:
run:
working-directory: desktop-client
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
# No Developer ID yet: stop electron-builder auto-discovering an identity,
# which otherwise fails the build on a real Mac rather than building unsigned.
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build dmg and zip (both architectures)
run: npx electron-builder --mac --x64 --arm64 --publish never
- uses: actions/upload-artifact@v3
with:
name: macos
path: |
desktop-client/dist/*.dmg
desktop-client/dist/*-mac-*.zip
if-no-files-found: error
publish:
# Separate job so only one process touches the release: parallel build jobs
# would race to create it and one would fail on the duplicate tag.
needs: [linux, windows, macos]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
path: artifacts
- name: Create release and attach artifacts
env:
# Gitea injects GITEA_TOKEN automatically, repo-scoped and short-lived.
# RELEASE_TOKEN is an optional override if wider rights are ever needed.
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -euo pipefail
artifact=$(ls dist/*.AppImage | head -1)
echo "Publishing $artifact for $GITHUB_REF_NAME"
# Reuse the release if the tag already has one, otherwise create it.
release_id=$(curl -sf -H "Authorization: token $TOKEN" \
"$API/releases/tags/$GITHUB_REF_NAME" | node -p \
"JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true)
if [ -z "${release_id:-}" ]; then
release_id=$(curl -sf -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
"$API/releases" | node -p \
"JSON.parse(require('fs').readFileSync(0,'utf8')).id")
if [ -z "${TOKEN:-}" ]; then
echo "No token available (GITEA_TOKEN missing and RELEASE_TOKEN unset)." >&2
exit 1
fi
curl -sf -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$artifact" \
"$API/releases/$release_id/assets?name=$(basename "$artifact")" \
-o /dev/null
echo "Attached $(basename "$artifact") to release $release_id"
# Report the HTTP status and body on failure. A bare `curl -sf | JSON.parse`
# dies with "Unexpected end of JSON input" and hides the real cause, which is
# 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
echo "No release for $GITHUB_REF_NAME yet; creating it."
release_id=$(api POST "/releases" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id")
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
while IFS= read -r f; do
name=$(basename "$f")
echo "Attaching $name ($(du -h "$f" | cut -f1))"
api POST "/releases/$release_id/assets?name=$name" -F "attachment=@$f" >/dev/null
found=$((found + 1))
done < <(find artifacts -type f \( -name '*.AppImage' -o -name '*.zip' \
-o -name '*.dmg' -o -name '*.exe' \) | sort)
if [ "$found" -eq 0 ]; then
echo "No artifacts found to attach; build jobs produced nothing." >&2
find artifacts -type f | head -20 >&2
exit 1
fi
echo "Attached $found artifacts to release $release_id"
+5 -2
View File
@@ -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);
+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",
"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"
+14 -6
View File
@@ -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": {
@@ -25,10 +25,11 @@
"main": "index.js"
},
"win": {
"target": "zip",
"sign": false,
"signAndEditExecutable": false,
"certificateSubjectName": null
"target": [
"nsis",
"zip"
],
"icon": "build/icon.png"
},
"linux": {
"target": [
@@ -42,10 +43,17 @@
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"mac": {
"target": [
"dmg",
"zip"
],
"icon": "build/icon.png",
"category": "public.app-category.music"
"category": "public.app-category.music",
"identity": null
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false
}
},
"dependencies": {
+3
View File
@@ -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());
+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');
// 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
+2 -35
View File
@@ -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 });