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:
@@ -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.");
|
||||
})();
|
||||
Reference in New Issue
Block a user