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.
458 lines
14 KiB
JavaScript
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();
|
|
}
|
|
})();
|