Files
NebulaBrowser/examples/extensions/return-youtube-dislike/content.js
T
Andrew Zambazos e270ba40d0 Add Return YouTube Dislike extension example
Add a new example extension that displays YouTube dislike counts using the public Return YouTube Dislike API. This is a simplified port of the Anarios/return-youtube-dislike project adapted for Nebula's native extension runtime (content-script only, no Chrome APIs). Includes proper attribution and respects rate limits and GPL-3.0 licensing requirements.
2026-07-12 20:55:03 +12:00

458 lines
14 KiB
JavaScript

/**
* Return YouTube Dislike — Nebula extension
*
* Uses the public Return YouTube Dislike API:
* https://returnyoutubedislikeapi.com/votes?videoId=...
*
* Inspired by / adapted from Anarios/return-youtube-dislike (GPL-3.0):
* https://github.com/Anarios/return-youtube-dislike
* Attribution: https://returnyoutubedislike.com
*
* This is a simplified page-script port for Nebula's native extension
* runtime (no chrome.* APIs, no vote submission, no premium features).
*/
(function () {
"use strict";
const API_BASE = "https://returnyoutubedislikeapi.com";
const ATTR_LINK = "https://returnyoutubedislike.com";
let currentVideoId = null;
let inFlight = null;
let lastApplied = null;
let pollTimer = null;
function numberFormat(value) {
const n = Number(value) || 0;
try {
return new Intl.NumberFormat(document.documentElement.lang || navigator.language || "en", {
notation: "compact",
compactDisplay: "short",
}).format(n);
} catch (_) {
return String(n);
}
}
function getVideoId(url) {
try {
const u = new URL(url || location.href);
if (u.pathname.startsWith("/shorts/")) {
return u.pathname.split("/")[2] || null;
}
if (u.pathname.startsWith("/live/")) {
return u.pathname.split("/")[2] || null;
}
if (u.pathname.startsWith("/embed/")) {
return u.pathname.split("/")[2] || null;
}
if (u.pathname.startsWith("/clip")) {
const meta =
document.querySelector("meta[itemprop='videoId']") ||
document.querySelector("meta[itemprop='identifier']");
return meta ? meta.content : null;
}
return u.searchParams.get("v");
} catch (_) {
return null;
}
}
function isWatchContext() {
const path = location.pathname;
return (
path === "/watch" ||
path.startsWith("/shorts/") ||
path.startsWith("/live/") ||
path.startsWith("/embed/") ||
path.startsWith("/clip")
);
}
function qs(root, selectors) {
const list = Array.isArray(selectors) ? selectors : [selectors];
for (const sel of list) {
if (!sel) continue;
const el = (root || document).querySelector(sel);
if (el) return el;
}
return null;
}
function getButtonsRoot() {
if (location.pathname.startsWith("/shorts/")) {
const nodes = document.querySelectorAll(
"reel-action-bar-view-model, #like-button > ytd-like-button-renderer, ytm-like-button-renderer"
);
for (const node of nodes) {
const rect = node.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) return node;
}
return nodes[0] || null;
}
// Try segmented first (new YouTube design)
let el = qs(document, ["ytd-segmented-like-dislike-button-renderer"]);
if (el) return el;
// Fallback to old design
el = qs(document, [
"#top-level-buttons-computed",
"ytd-menu-renderer.ytd-watch-metadata",
".slim-video-action-bar-actions",
]);
return el || null;
}
function getLikeButton() {
// New design (segmented)
let btn = qs(document, ["#segmented-like-button"]);
if (btn) return btn;
// Fallback
const root = getButtonsRoot();
if (root) {
btn = qs(root, [
"like-button-view-model",
"yt-spec-button-shape-next",
"ytd-like-button-renderer",
":first-child",
]);
if (btn) return btn;
}
return qs(document, ["like-button-view-model", "ytd-like-button-renderer"]) || null;
}
function getDislikeButton() {
// New design (segmented)
let btn = qs(document, ["#segmented-dislike-button"]);
if (btn) return btn;
// Fallback
const root = getButtonsRoot();
if (root) {
btn = qs(root, [
"dislike-button-view-model",
"yt-spec-button-shape-next",
"ytd-toggle-button-renderer",
":nth-child(2)",
]);
if (btn) return btn;
}
return qs(document, ["dislike-button-view-model", "#dislike-button", "ytd-toggle-button-renderer"]) || null;
}
function getNativeButton(container) {
return container ? qs(container, ["button", "tp-yt-paper-button#button"]) : null;
}
function getLikeCountFromButton() {
try {
const like = getLikeButton();
const btn = getNativeButton(like) || like;
const label = btn && (btn.getAttribute("aria-label") || btn.textContent || "");
if (!label) return null;
const text = String(label).trim();
// Parse abbreviated numbers: "28M" → 28000000, "3K" → 3000, "82" → 82
const match = text.match(/^([\d.]+)\s*([MKmk]?)(?:\s|$)/);
if (!match) return null;
let num = parseFloat(match[1]);
if (!Number.isFinite(num)) return null;
const suffix = (match[2] || "").toUpperCase();
if (suffix === "M") num *= 1_000_000;
else if (suffix === "K") num *= 1_000;
return Math.round(num);
} catch (_) {
return null;
}
}
function matchDislikeButtonWidth(dislikeButton) {
if (!dislikeButton) return;
try {
const likeBtn = getLikeButton();
if (!likeBtn) return;
const likeWidth = window.getComputedStyle(likeBtn).width;
if (!likeWidth || likeWidth === "auto") return;
dislikeButton.style.setProperty("min-width", likeWidth, "important");
dislikeButton.style.setProperty("flex-shrink", "0", "important");
const native = getNativeButton(dislikeButton);
if (native) {
native.style.setProperty("min-width", likeWidth, "important");
native.style.setProperty("flex-shrink", "0", "important");
}
} catch (_) {
// Ignore — cosmetic only.
}
}
function ensureDislikeLabel(dislikeButton) {
matchDislikeButtonWidth(dislikeButton);
let label = dislikeButton.querySelector(".ryd-dislike-count");
if (label) return label;
const native = getNativeButton(dislikeButton) || dislikeButton;
label = document.createElement("span");
label.className = "ryd-dislike-count";
label.setAttribute("data-ryd", "1");
label.title = "Dislikes via Return YouTube Dislike — " + ATTR_LINK;
// Prefer injecting into the button so YouTube's layout keeps the text.
const textHost =
qs(native, [
".yt-spec-button-shape-next__button-text-content",
".ytSpecButtonShapeNextButtonTextContent",
]) || native;
// Switch icon-only dislike buttons to icon+text style when possible.
if (native && native.classList) {
native.classList.remove("yt-spec-button-shape-next--icon-button");
native.classList.add("yt-spec-button-shape-next--icon-leading");
}
textHost.appendChild(label);
return label;
}
function setDislikeText(text) {
const dislike = getDislikeButton();
if (!dislike) return false;
const label = ensureDislikeLabel(dislike);
if (label.textContent !== text) {
label.textContent = text;
}
return true;
}
function createOrUpdateBar(likes, dislikes) {
if (location.pathname.startsWith("/shorts/")) return;
const likeBtn = getLikeButton();
const dislikeBtn = getDislikeButton();
const actions = qs(document, [
"#top-level-buttons-computed",
"#actions-inner",
"#actions",
"#menu-container",
]);
if (!likeBtn || !dislikeBtn || !actions) {
console.log("[RYD] Bar creation: missing", {
likeBtn: !!likeBtn,
dislikeBtn: !!dislikeBtn,
actions: !!actions,
});
return;
}
const total = likes + dislikes;
const widthPercent = total > 0 ? (likes / total) * 100 : 50;
const tooltip = `${likes.toLocaleString()} / ${dislikes.toLocaleString()}${widthPercent.toFixed(1)}% liked`;
const likeRect = likeBtn.getBoundingClientRect();
const dislikeRect = dislikeBtn.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
const pillLeft = Math.max(0, likeRect.left - actionsRect.left);
const pillWidth = Math.max(0, dislikeRect.right - likeRect.left);
const pillBottom = Math.max(likeRect.bottom, dislikeRect.bottom) - actionsRect.top;
if (!pillWidth) return;
let tooltipEl = actions.querySelector(".ryd-tooltip");
if (!tooltipEl) {
console.log("[RYD] Creating new bar under like/dislike buttons");
// Create without innerHTML to avoid Trusted Types CSP
tooltipEl = document.createElement("div");
tooltipEl.className = "ryd-tooltip";
const bar = document.createElement("div");
bar.id = "ryd-bar";
const barContainer = document.createElement("div");
barContainer.id = "ryd-bar-container";
barContainer.appendChild(bar);
tooltipEl.appendChild(barContainer);
tooltipEl.title = tooltip + " · " + ATTR_LINK;
if (getComputedStyle(actions).position === "static") {
actions.style.setProperty("position", "relative", "important");
}
actions.style.setProperty("overflow", "visible", "important");
actions.style.setProperty("padding-bottom", "10px", "important");
actions.appendChild(tooltipEl);
console.log("[RYD] Bar created");
}
tooltipEl.style.left = pillLeft + "px";
tooltipEl.style.top = pillBottom + 2 + "px";
tooltipEl.style.width = pillWidth + "px";
tooltipEl.title = tooltip + " · " + ATTR_LINK;
const bar = tooltipEl.querySelector("#ryd-bar");
if (bar) {
bar.style.width = widthPercent + "%";
}
}
async function fetchVotes(videoId) {
const likeCount = getLikeCountFromButton();
const url =
API_BASE +
"/votes?videoId=" +
encodeURIComponent(videoId) +
(likeCount != null ? "&likeCount=" + encodeURIComponent(String(likeCount)) : "");
console.log("[RYD] Fetching:", url);
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
credentials: "omit",
});
if (response.status === 429) {
throw new Error("rate-limited");
}
if (!response.ok) {
throw new Error("http-" + response.status);
}
const data = await response.json();
console.log("[RYD] Response:", data);
if (!data || typeof data.dislikes !== "number") {
throw new Error("bad-payload");
}
return data;
}
async function applyForVideo(videoId) {
if (!videoId) {
console.log("[RYD] No video ID");
return;
}
if (inFlight === videoId) {
console.log("[RYD] Already in-flight for", videoId);
return;
}
if (lastApplied === videoId && document.querySelector(".ryd-dislike-count")) {
console.log("[RYD] Already applied for", videoId);
return;
}
console.log("[RYD] Applying for video:", videoId);
inFlight = videoId;
try {
const data = await fetchVotes(videoId);
if (getVideoId(location.href) !== videoId) {
console.log("[RYD] Video changed during fetch");
return;
}
const likes = getLikeCountFromButton() || Number(data.likes) || 0;
const dislikes = Number(data.dislikes) || 0;
console.log("[RYD] Likes:", likes, "Dislikes:", dislikes);
// Retry UI injection briefly — YouTube hydrates buttons asynchronously.
let tries = 0;
const paint = () => {
const dislikeBtn = getDislikeButton();
console.log("[RYD] Paint attempt", tries, "- dislike button:", dislikeBtn ? "found" : "missing");
const ok = setDislikeText(numberFormat(dislikes));
if (ok) {
console.log("[RYD] Successfully set dislike text");
createOrUpdateBar(likes, dislikes);
lastApplied = videoId;
return;
}
if (++tries < 25) {
window.setTimeout(paint, 200);
} else {
console.warn("[RYD] Failed to find dislike button after 25 tries");
}
};
paint();
} catch (err) {
if (getVideoId(location.href) === videoId) {
setDislikeText("—");
}
console.error("[RYD] Error:", err.message);
} finally {
if (inFlight === videoId) inFlight = null;
}
}
function refresh() {
if (!isWatchContext()) {
currentVideoId = null;
return;
}
const videoId = getVideoId(location.href);
if (!videoId) return;
if (videoId !== currentVideoId) {
currentVideoId = videoId;
lastApplied = null;
applyForVideo(videoId);
} else if (lastApplied !== videoId) {
applyForVideo(videoId);
} else {
// Re-paint if YouTube rebuilt the button DOM.
if (!document.querySelector(".ryd-dislike-count")) {
lastApplied = null;
applyForVideo(videoId);
}
}
}
function start() {
refresh();
document.addEventListener("yt-navigate-finish", refresh, true);
window.addEventListener("yt-navigate-finish", refresh, true);
window.addEventListener("popstate", refresh);
// YouTube SPA fallback when custom events are missed.
let lastHref = location.href;
window.setInterval(() => {
if (location.href !== lastHref) {
lastHref = location.href;
refresh();
return;
}
if (isWatchContext() && currentVideoId && !document.querySelector(".ryd-dislike-count")) {
lastApplied = null;
refresh();
}
}, 1500);
const observer = new MutationObserver(() => {
if (isWatchContext() && currentVideoId && !document.querySelector(".ryd-dislike-count")) {
lastApplied = null;
refresh();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
})();