Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e49078ee03 | ||
|
|
f73ea93dde | ||
|
|
d218f4c6f3 | ||
|
|
1f79d7bcfe | ||
|
|
394dc7107c | ||
|
|
f8d10d3a24 | ||
|
|
72485d44ff | ||
|
|
240535506b | ||
|
|
289cb6d4b1 | ||
|
|
9240dc3cb6 |
+157
-29
@@ -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.
|
# Builds each platform natively on a v* tag and publishes one 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).
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -10,18 +23,19 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
appimage:
|
linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: desktop-client
|
working-directory: desktop-client
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
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
|
- name: Verify tag matches package.json version
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
run: |
|
run: |
|
||||||
@@ -29,7 +43,7 @@ jobs:
|
|||||||
tag_version="${GITHUB_REF_NAME#v}"
|
tag_version="${GITHUB_REF_NAME#v}"
|
||||||
if [ "$pkg_version" != "$tag_version" ]; then
|
if [ "$pkg_version" != "$tag_version" ]; then
|
||||||
echo "Tag $GITHUB_REF_NAME does not match package.json version $pkg_version." >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -42,31 +56,145 @@ jobs:
|
|||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: npx electron-builder --linux AppImage --publish never
|
run: npx electron-builder --linux AppImage --publish never
|
||||||
|
|
||||||
- name: Attach AppImage to release
|
- uses: actions/upload-artifact@v3
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
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:
|
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 }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
artifact=$(ls dist/*.AppImage | head -1)
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
echo "Publishing $artifact for $GITHUB_REF_NAME"
|
echo "No token available (GITEA_TOKEN missing and RELEASE_TOKEN unset)." >&2
|
||||||
|
exit 1
|
||||||
# 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")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -sf -X POST -H "Authorization: token $TOKEN" \
|
# Report the HTTP status and body on failure. A bare `curl -sf | JSON.parse`
|
||||||
-F "attachment=@$artifact" \
|
# dies with "Unexpected end of JSON input" and hides the real cause, which is
|
||||||
"$API/releases/$release_id/assets?name=$(basename "$artifact")" \
|
# how the v1.0.0 run failed on a missing token.
|
||||||
-o /dev/null
|
api() {
|
||||||
echo "Attached $(basename "$artifact") to release $release_id"
|
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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
"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,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": {
|
||||||
@@ -16,25 +16,45 @@
|
|||||||
"!tests/**"
|
"!tests/**"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{ "from": "../overlay", "to": "overlay" }
|
{
|
||||||
|
"from": "../overlay",
|
||||||
|
"to": "overlay"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"extraMetadata": {
|
"extraMetadata": {
|
||||||
"main": "index.js"
|
"main": "index.js"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": "zip",
|
"target": [
|
||||||
"sign": false,
|
"nsis",
|
||||||
"signAndEditExecutable": false,
|
"zip"
|
||||||
"certificateSubjectName": null
|
],
|
||||||
|
"icon": "build/icon.png"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": ["AppImage"],
|
"target": [
|
||||||
|
"AppImage"
|
||||||
|
],
|
||||||
"icon": "build/icon.png",
|
"icon": "build/icon.png",
|
||||||
"category": "AudioVideo",
|
"category": "AudioVideo",
|
||||||
"synopsis": "YouTube Music player with a now-playing overlay for OBS",
|
"synopsis": "YouTube Music player with a now-playing overlay for OBS",
|
||||||
"maintainer": "What's That Music"
|
"maintainer": "What's That Music"
|
||||||
},
|
},
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"category": "public.app-category.music",
|
||||||
|
"identity": null
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"perMachine": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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');
|
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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user