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