/** * 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(); } })();