Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12cfca8b17 | ||
|
|
0e5b20498a | ||
|
|
e49078ee03 | ||
|
|
f73ea93dde | ||
|
|
d218f4c6f3 | ||
|
|
1f79d7bcfe | ||
|
|
394dc7107c | ||
|
|
f8d10d3a24 |
Executable
+75
@@ -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"
|
||||||
@@ -6,11 +6,13 @@ name: Build release artifacts
|
|||||||
# queues silently rather than failing, so register all three before tagging:
|
# queues silently rather than failing, so register all three before tagging:
|
||||||
# ubuntu-latest Linux AppImage
|
# ubuntu-latest Linux AppImage
|
||||||
# windows-latest Windows NSIS installer + portable zip
|
# windows-latest Windows NSIS installer + portable zip
|
||||||
# macos-arm64 macOS dmg + zip, both architectures (M-series builds both)
|
# macos-latest macOS dmg + zip, both architectures (M-series builds both)
|
||||||
#
|
#
|
||||||
# Nothing cross-builds any more: dmg requires macOS, and the Windows exe needs
|
# Nothing cross-builds any more: dmg requires macOS, and the Windows exe needs
|
||||||
# rcedit on Windows to get its icon and version resources.
|
# 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
|
# 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
|
# from package.json and remove CSC_IDENTITY_AUTO_DISCOVERY; for Windows add the
|
||||||
# cert secrets. Until then users hit Gatekeeper and SmartScreen warnings.
|
# cert secrets. Until then users hit Gatekeeper and SmartScreen warnings.
|
||||||
@@ -89,7 +91,7 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
macos:
|
macos:
|
||||||
runs-on: macos-arm64
|
runs-on: macos-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: desktop-client
|
working-directory: desktop-client
|
||||||
@@ -127,45 +129,84 @@ 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
|
||||||
|
|
||||||
- name: Create release and attach artifacts
|
- name: Create release and attach artifacts
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
# 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
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
echo "RELEASE_TOKEN secret is not set." >&2
|
echo "No token available (GITEA_TOKEN missing and RELEASE_TOKEN unset)." >&2
|
||||||
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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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