Add YouTube Music now-playing server, OBS overlay, and browser extension

Node.js server on port 9095 receives now-playing data via POST and broadcasts
to connected WebSocket clients. Includes a Firefox/Chrome extension that scrapes
YouTube Music's player bar every 3s, an OBS-ready transparent overlay with
animated equalizer bars, and an image proxy to bypass CDN restrictions in OBS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 11:55:04 -07:00
co-authored by Claude Opus 4.6
parent 74bbf4e9b6
commit e36c4d1a74
9 changed files with 1563 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
// YTMusic Now Playing - Content Script
// Scrapes now-playing info from YouTube Music and sends it to a configurable server.
(function () {
"use strict";
const DEFAULT_SERVER_URL = "http://localhost:9095";
const POLL_INTERVAL_MS = 3000;
let lastSentData = null;
let serverUrl = DEFAULT_SERVER_URL;
// Load server URL from storage
chrome.storage.sync.get({ serverUrl: DEFAULT_SERVER_URL }, (result) => {
serverUrl = result.serverUrl;
});
// Listen for storage changes so the popup can update the URL live
chrome.storage.onChanged.addListener((changes) => {
if (changes.serverUrl) {
serverUrl = changes.serverUrl.newValue || DEFAULT_SERVER_URL;
}
});
/**
* Scrape the current now-playing information from the YouTube Music DOM.
*/
function scrapeNowPlaying() {
// Song title
const titleEl =
document.querySelector("ytmusic-player-bar .title") ||
document.querySelector(".ytmusic-player-bar .title");
const title = titleEl ? titleEl.textContent.trim() : null;
// Artist / byline
const bylineEl =
document.querySelector("ytmusic-player-bar .byline") ||
document.querySelector(".ytmusic-player-bar .byline");
const artist = bylineEl ? bylineEl.textContent.trim() : null;
// Album art URL
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;
// Play state: the play-pause button's title attribute indicates the state.
// When music is playing the button shows "Pause" (because clicking it would pause).
const playPauseBtn = document.querySelector("#play-pause-button");
let isPlaying = false;
if (playPauseBtn) {
const btnTitle = (
playPauseBtn.getAttribute("title") ||
playPauseBtn.getAttribute("aria-label") ||
""
).toLowerCase();
// If the button says "pause", music is currently playing
isPlaying = btnTitle.includes("pause");
}
// Time info: usually shows "elapsed / duration" like "1:23 / 3:45"
const timeInfoEl =
document.querySelector("ytmusic-player-bar .time-info") ||
document.querySelector(".ytmusic-player-bar .time-info");
let elapsed = null;
let duration = null;
if (timeInfoEl) {
const timeText = timeInfoEl.textContent.trim();
const parts = timeText.split("/").map((s) => s.trim());
if (parts.length === 2) {
elapsed = parts[0];
duration = parts[1];
}
}
// If there's no title, nothing is playing
if (!title) {
return null;
}
return {
title,
artist,
albumArt,
isPlaying,
elapsed,
duration,
};
}
/**
* Check if two data objects are equal (shallow comparison of relevant fields).
*/
function dataChanged(a, b) {
if (a === null && b === null) return false;
if (a === null || b === null) return true;
return (
a.title !== b.title ||
a.artist !== b.artist ||
a.albumArt !== b.albumArt ||
a.isPlaying !== b.isPlaying ||
a.elapsed !== b.elapsed ||
a.duration !== b.duration
);
}
/**
* Send data to the server.
*/
async function sendToServer(data) {
const url = `${serverUrl}/api/now-playing`;
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
console.warn(
`[YTMusic Now Playing] Server responded with ${response.status}`
);
}
} catch (err) {
console.warn(`[YTMusic Now Playing] Failed to send data:`, err.message);
}
}
/**
* Main polling loop.
*/
function poll() {
const data = scrapeNowPlaying();
if (dataChanged(data, lastSentData)) {
lastSentData = data;
if (data) {
sendToServer(data);
} else {
// Send a "nothing playing" payload
sendToServer({ isPlaying: false });
}
}
}
// Start polling
setInterval(poll, POLL_INTERVAL_MS);
// Run once immediately
poll();
console.log("[YTMusic Now Playing] Content script loaded.");
})();