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.");
})();
+30
View File
@@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "YTMusic Now Playing",
"version": "1.0.0",
"description": "Sends currently playing song info to a Now Playing server",
"browser_specific_settings": {
"gecko": {
"id": "ytmusic-now-playing@whats-that-music",
"strict_min_version": "109.0"
}
},
"permissions": ["storage"],
"host_permissions": [
"https://music.youtube.com/*",
"http://localhost:9095/*",
"http://localhost/*",
"http://127.0.0.1/*",
"https://*/*"
],
"content_scripts": [
{
"matches": ["https://music.youtube.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html"
}
}
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
width: 320px;
padding: 16px;
background: #1a1a2e;
color: #e0e0e0;
}
h1 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: #ff4e45;
}
label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: #aaa;
}
input[type="text"] {
width: 100%;
padding: 8px 10px;
font-size: 13px;
border: 1px solid #333;
border-radius: 4px;
background: #16213e;
color: #e0e0e0;
outline: none;
}
input[type="text"]:focus {
border-color: #ff4e45;
}
button {
margin-top: 10px;
width: 100%;
padding: 8px;
font-size: 13px;
font-weight: 600;
border: none;
border-radius: 4px;
background: #ff4e45;
color: #fff;
cursor: pointer;
}
button:hover {
background: #e0433b;
}
#status {
margin-top: 10px;
font-size: 12px;
text-align: center;
min-height: 16px;
}
.status-ok { color: #4caf50; }
.status-err { color: #f44336; }
.status-info { color: #aaa; }
</style>
</head>
<body>
<h1>YTMusic Now Playing</h1>
<label for="serverUrl">Server URL</label>
<input type="text" id="serverUrl" placeholder="http://localhost:9095">
<button id="save">Save</button>
<div id="status" class="status-info"></div>
<script src="popup.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
// YTMusic Now Playing - Popup Script
(function () {
"use strict";
const DEFAULT_SERVER_URL = "http://localhost:9095";
const serverUrlInput = document.getElementById("serverUrl");
const saveBtn = document.getElementById("save");
const statusEl = document.getElementById("status");
function setStatus(message, type) {
statusEl.textContent = message;
statusEl.className = `status-${type}`;
}
// Load saved URL on popup open
chrome.storage.sync.get({ serverUrl: DEFAULT_SERVER_URL }, (result) => {
serverUrlInput.value = result.serverUrl;
checkConnection(result.serverUrl);
});
// Save button handler
saveBtn.addEventListener("click", () => {
const url = serverUrlInput.value.trim().replace(/\/+$/, "");
if (!url) {
setStatus("Please enter a valid URL.", "err");
return;
}
chrome.storage.sync.set({ serverUrl: url }, () => {
setStatus("Saved!", "ok");
checkConnection(url);
});
});
// Check connection to the server
async function checkConnection(url) {
try {
const response = await fetch(`${url}/api/now-playing`, {
method: "GET",
});
if (response.ok) {
setStatus("Connected to server.", "ok");
} else {
setStatus(`Server returned ${response.status}.`, "err");
}
} catch (err) {
setStatus("Cannot reach server.", "err");
}
}
})();