Raw Source
g31w0fw0rldgmail.com / YouTube Channel Tools

// ==UserScript==
// @name         YouTube Channel Tools
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Conteo de dislikes (read-only via API publica de Return YouTube Dislike) + resaltado de canales favoritos / a evitar en feed, busqueda, sidebar, watch y Shorts. Auto-like / auto-dislike opcional (tambien en Shorts) con guardarrailes. Sin envío de votos.
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @author       g31w0fw0rld
// @license      MIT
// @downloadURL  https://github.com/g31w0fw0rld/youtube-channel-tools/raw/main/youtube-channel-tools.user.js
// @updateURL    https://github.com/g31w0fw0rld/youtube-channel-tools/raw/main/youtube-channel-tools.user.js
// @connect      returnyoutubedislikeapi.com
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
    "use strict";

    const SCRIPT_VERSION = "1.0.0";
    const pageWindow = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
    const STORAGE_KEY = "yct-state";
    const log = (...a) => console.log("[YT Channel Tools]", ...a);

    // ---------- TRUSTED TYPES SHIM ----------
    // YouTube enforces Trusted Types CSP, so raw innerHTML assignment fails.
    // Try to register a policy; if denied, fall back to DOMParser-based hydration.
    let ttPolicy = null;
    try {
        if (window.trustedTypes && window.trustedTypes.createPolicy) {
            ttPolicy = window.trustedTypes.createPolicy("yct", { createHTML: s => s });
        }
    } catch (e) { log("TT policy denied, usando DOMParser fallback:", e.message); }

    function setHTML(el, html) {
        if (ttPolicy) {
            el.innerHTML = ttPolicy.createHTML(html);
            return;
        }
        const doc = new DOMParser().parseFromString(`<!doctype html><body>${html}</body>`, "text/html");
        el.replaceChildren(...Array.from(doc.body.childNodes).map(n => document.importNode(n, true)));
    }

    // ---------- STATE ----------

    const state = {
        favorites: {},
        avoid: {},
        settings: {
            hideAvoid: false,
            showWatchBanner: true,
            autoActEnabled: false,
            autoActDryRun: false,
            autoActMinWatchSec: 10,
            autoActDelayMinSec: 3,
            autoActDelayMaxSec: 15,
            autoActRateLimitPerHour: 5,
            showFab: true,
        },
    };

    function loadState() {
        try {
            const raw = GM_getValue(STORAGE_KEY, null);
            if (!raw) return;
            const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
            Object.assign(state.favorites, parsed.favorites || {});
            Object.assign(state.avoid, parsed.avoid || {});
            Object.assign(state.settings, parsed.settings || {});
        } catch (e) { log("loadState error", e); }
    }

    function saveState() {
        try { GM_setValue(STORAGE_KEY, JSON.stringify(state)); }
        catch (e) { log("saveState error", e); }
    }

    function listFor(kind) { return kind === "fav" ? state.favorites : state.avoid; }

    function inList(ucId, handle) {
        if (ucId && state.favorites[ucId]) return "fav";
        if (ucId && state.avoid[ucId]) return "avoid";
        if (handle) {
            for (const e of Object.values(state.favorites)) if (e.handle === handle) return "fav";
            for (const e of Object.values(state.avoid)) if (e.handle === handle) return "avoid";
        }
        return null;
    }

    function channelKey(channel) {
        return channel?.ucId || channel?.handle || null;
    }

    function addChannel(kind, channel) {
        const key = channelKey(channel);
        if (!key) return false;
        const other = kind === "fav" ? state.avoid : state.favorites;
        delete other[key];
        // Also clean any stale entry with the alternate key (handle vs ucId migration)
        if (channel.ucId && channel.handle) {
            delete other[channel.handle];
            delete listFor(kind)[channel.handle];
        }
        listFor(kind)[key] = {
            ucId: channel.ucId || null,
            name: channel.name || null,
            handle: channel.handle || null,
            addedAt: Date.now(),
        };
        saveState();
        return true;
    }

    function removeChannel(kind, key) {
        if (!listFor(kind)[key]) return false;
        delete listFor(kind)[key];
        saveState();
        return true;
    }

    // ---------- TOAST ----------

    function toast(msg, kind = "info", ms = 3000) {
        const wrap = ensureToastWrap();
        const el = document.createElement("div");
        el.className = `yct-toast yct-toast-${kind}`;
        el.textContent = msg;
        wrap.appendChild(el);
        requestAnimationFrame(() => el.classList.add("yct-toast-in"));
        setTimeout(() => {
            el.classList.remove("yct-toast-in");
            setTimeout(() => el.remove(), 250);
        }, ms);
    }

    function ensureToastWrap() {
        let w = document.getElementById("yct-toast-wrap");
        if (!w) {
            w = document.createElement("div");
            w.id = "yct-toast-wrap";
            (document.body || document.documentElement).appendChild(w);
        }
        return w;
    }

    // ---------- DISLIKE COUNT ----------

    const API = "https://returnyoutubedislikeapi.com/votes?videoId=";
    const dislikeCache = new Map();
    const CACHE_TTL = 5 * 60 * 1000;
    const MAX_ERRORS = 3;
    let dislikeErrors = 0;
    let dislikeKilled = false;
    let watchVideoId = null;

    function isWatchPage() { return location.pathname.startsWith("/watch"); }
    function isShortsPage() { return location.pathname.startsWith("/shorts/"); }
    function isVideoPage() { return isWatchPage() || isShortsPage(); }

    function getVideoId() {
        try {
            if (isShortsPage()) {
                const m = location.pathname.match(/^\/shorts\/([^/?#]+)/);
                return m ? m[1] : null;
            }
            return new URL(location.href).searchParams.get("v");
        } catch { return null; }
    }

    // Devuelve el primer elemento visible que matchea el selector (para Shorts,
    // que precarga reels adyacentes; solo nos interesa el que esta en pantalla).
    function findVisible(selector, root = document) {
        const els = root.querySelectorAll(selector);
        for (const el of els) {
            if (el.offsetParent === null) continue;
            const rect = el.getBoundingClientRect();
            if (rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight) return el;
        }
        return els[0] || null;
    }

    // Contenedor del Short activo. Usamos el ytd-reel-video-renderer visible si
    // existe; si no, caemos al overlay/channel bar visibles. Sirve para acotar
    // selectores de boton de like/dislike y de canal.
    function getActiveShortsScope() {
        return findVisible('ytd-reel-video-renderer[is-active]')
            || findVisible('ytd-reel-video-renderer:not([hidden])')
            || findVisible('ytd-reel-video-renderer')
            || document;
    }

    function fmtCount(n) {
        if (typeof n !== "number" || !isFinite(n) || n < 0) return null;
        const trim = s => s.replace(/\.0$/, "");
        if (n < 1000) return String(n);
        if (n < 1e6) return trim((n / 1e3).toFixed(n < 1e4 ? 1 : 0)) + "K";
        if (n < 1e9) return trim((n / 1e6).toFixed(n < 1e7 ? 1 : 0)) + "M";
        return trim((n / 1e9).toFixed(1)) + "B";
    }

    function fetchVotes(videoId) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: API + encodeURIComponent(videoId),
                timeout: 8000,
                headers: { Accept: "application/json" },
                onload: r => {
                    if (r.status < 200 || r.status >= 300) return reject(new Error("HTTP " + r.status));
                    try { resolve(JSON.parse(r.responseText)); }
                    catch (e) { reject(e); }
                },
                onerror: () => reject(new Error("network")),
                ontimeout: () => reject(new Error("timeout")),
            });
        });
    }

    async function getVotes(videoId) {
        const now = Date.now();
        const hit = dislikeCache.get(videoId);
        if (hit && now - hit.ts < CACHE_TTL) return hit.count;
        const data = await fetchVotes(videoId);
        const count = data && typeof data.dislikes === "number" ? data.dislikes : null;
        if (count == null) throw new Error("invalid");
        dislikeCache.set(videoId, { count, ts: now });
        return count;
    }

    function findDislikeBtn() {
        if (isShortsPage()) {
            const scope = getActiveShortsScope();
            return findVisible('dislike-button-view-model button', scope)
                || findVisible('dislike-button-view-model button');
        }
        if (isWatchPage()) {
            // ESTRICTO: solo botones DENTRO de ytd-watch-metadata. Si no
            // estan, devolvemos null y dejamos que el tick reintente. Ningun
            // fallback al selector global porque podria agarrar un dislike
            // de un Shorts sobrante en el DOM (SPA no siempre lo remueve).
            const meta = findVisible('ytd-watch-metadata') || document.querySelector('ytd-watch-metadata');
            if (!meta) return null;
            return meta.querySelector('dislike-button-view-model button')
                || meta.querySelector('#segmented-dislike-button button')
                || meta.querySelector('button[aria-label*="dislike" i]');
        }
        return null;
    }

    function paintDislike(text) {
        const btn = findDislikeBtn();
        if (!btn) return false;

        // Shorts: la etiqueta vive en .ytSpecButtonShapeWithLabelLabel (sibling
        // del <button> dentro de un <label>). Reemplazamos el texto "No me gusta"
        // por el conteo formateado.
        if (isShortsPage()) {
            const label = btn.parentElement?.querySelector('.ytSpecButtonShapeWithLabelLabel .ytAttributedStringHost')
                       || btn.closest('label')?.querySelector('.ytSpecButtonShapeWithLabelLabel .ytAttributedStringHost');
            if (!label) return false;
            if (label.dataset.yctApplied === text) return true;
            label.textContent = text;
            label.dataset.yctApplied = text;
            return true;
        }

        // /watch: estructura distinta — span con la clase del shape system.
        let node = btn.querySelector('.yt-spec-button-shape-next__button-text-content');
        if (!node) {
            node = btn.querySelector('span[data-yct-dislike]');
            if (!node) {
                node = document.createElement('span');
                node.setAttribute('data-yct-dislike', '1');
                node.className = 'yt-spec-button-shape-next__button-text-content';
                node.style.marginLeft = '6px';
                btn.appendChild(node);
            }
        }
        if (node.dataset.yctApplied === text) return true;
        node.textContent = text;
        node.dataset.yctApplied = text;
        return true;
    }

    async function updateDislike() {
        if (dislikeKilled) return;
        if (!isVideoPage()) return;
        const videoId = getVideoId();
        if (!videoId) return;
        watchVideoId = videoId;

        let count;
        try {
            count = await getVotes(videoId);
            dislikeErrors = 0;
        } catch (e) {
            dislikeErrors++;
            log("dislike fetch error:", e.message, `(${dislikeErrors}/${MAX_ERRORS})`);
            if (dislikeErrors >= MAX_ERRORS) {
                dislikeKilled = true;
                log("dislike kill switch ON");
            }
            return;
        }
        if (watchVideoId !== videoId) return;

        const text = fmtCount(count);
        if (!text) return;

        let tries = 0;
        const tick = () => {
            if (watchVideoId !== videoId || dislikeKilled) return;
            if (paintDislike(text)) return;
            if (++tries < 30) setTimeout(tick, 250);
        };
        tick();

        // Re-paint diferido. YouTube suele re-renderizar el boton 1–3 s despues
        // de la navegacion (al cargar metadata extra), borrando nuestro span.
        // Como paintDislike es idempotente (data-yct-applied), estos calls son
        // no-ops si el span sigue, y re-pintan si fue borrado.
        [600, 1500, 3000, 5000].forEach(ms => {
            setTimeout(() => {
                if (watchVideoId === videoId && !dislikeKilled) paintDislike(text);
            }, ms);
        });
    }

    // ---------- AUTO-ACTIONS (guarded) ----------

    const session = {
        playedByVideo: new Map(),
        lastTickTs: 0,
        actedVideos: new Set(),
        actionTimestamps: [],
        scheduledFor: null,
    };

    const sleep = ms => new Promise(r => setTimeout(r, ms));

    function tickPlayback() {
        // En Shorts puede haber varios reels precargados con su propio <video>;
        // queremos contar segundos solo del reel visible.
        let v;
        if (isShortsPage()) {
            const scope = getActiveShortsScope();
            v = scope.querySelector?.("video.html5-main-video")
              || document.querySelector("video.html5-main-video");
        } else {
            v = document.querySelector("video.html5-main-video");
        }
        const now = Date.now();
        const last = session.lastTickTs;
        session.lastTickTs = now;
        if (!v || !last) return;
        const vid = getVideoId();
        if (!vid) return;
        if (v.paused || v.seeking || v.ended) return;
        const delta = (now - last) / 1000;
        if (delta <= 0 || delta > 5) return;
        session.playedByVideo.set(vid, (session.playedByVideo.get(vid) || 0) + delta);
    }

    function isLoggedIn() {
        return !!document.querySelector('#avatar-btn img, ytd-topbar-menu-button-renderer #avatar-btn img');
    }

    function findLikeBtn() {
        if (isShortsPage()) {
            const scope = getActiveShortsScope();
            return findVisible('like-button-view-model button', scope)
                || findVisible('like-button-view-model button');
        }
        if (isWatchPage()) {
            // ESTRICTO: igual que findDislikeBtn — solo dentro de ytd-watch-metadata
            // para no agarrar botones de Shorts sobrantes.
            const meta = findVisible('ytd-watch-metadata') || document.querySelector('ytd-watch-metadata');
            if (!meta) return null;
            return meta.querySelector('like-button-view-model button')
                || meta.querySelector('#segmented-like-button button')
                || meta.querySelector('segmented-like-dislike-button-view-model button');
        }
        return null;
    }

    function pruneRateLimit() {
        const now = Date.now();
        session.actionTimestamps = session.actionTimestamps.filter(ts => now - ts < 3600_000);
    }

    function diagnoseAutoAct() {
        if (!state.settings.autoActEnabled) return "Auto-action: desactivado en Ajustes";
        if (!isVideoPage()) return "Auto-action: no estás en /watch ni /shorts";
        const videoId = getVideoId();
        if (!videoId) return "Auto-action: sin videoId";
        const ch = detectCurrentChannel();
        if (!ch) return "Auto-action: canal del video no detectado";
        const kind = inList(ch.ucId, ch.handle);
        if (!kind) return `Auto-action: ${ch.name || ch.handle || ch.ucId} no está en ninguna lista`;
        if (!isLoggedIn()) return "Auto-action: sesión no detectada (¿estás logueado?)";
        if (session.actedVideos.has(videoId)) return "Auto-action: ya actuó en este video (esta sesión)";
        const played = session.playedByVideo.get(videoId) || 0;
        const min = state.settings.autoActMinWatchSec;
        const dMax = state.settings.autoActDelayMaxSec;
        if (played < min) return `Auto-action: esperando watch time ${played.toFixed(0)}s / ${min}s`;
        if (session.scheduledFor === videoId) return `Auto-action: programada, esperando delay (max ${dMax}s)`;
        return `Auto-action: lista para ${kind === "fav" ? "like" : "dislike"} en próximos segundos`;
    }

    async function maybeAutoAct() {
        if (!state.settings.autoActEnabled) return;
        if (!isVideoPage()) return;
        const videoId = getVideoId();
        if (!videoId) return;
        if (session.actedVideos.has(videoId)) return;
        if (session.scheduledFor === videoId) return;

        const ch = detectCurrentChannel();
        if (!ch) return;
        const kind = inList(ch.ucId, ch.handle);
        if (!kind) return;

        if (!isLoggedIn()) { log("auto-act skip: no sesion"); return; }

        session.scheduledFor = videoId;

        const minSec = Math.max(1, state.settings.autoActMinWatchSec);
        while ((session.playedByVideo.get(videoId) || 0) < minSec) {
            if (getVideoId() !== videoId) { session.scheduledFor = null; return; }
            await sleep(1000);
        }

        const dMin = Math.max(0, state.settings.autoActDelayMinSec);
        const dMax = Math.max(dMin, state.settings.autoActDelayMaxSec);
        const delayMs = (dMin + Math.random() * (dMax - dMin)) * 1000;
        await sleep(delayMs);
        if (getVideoId() !== videoId) { session.scheduledFor = null; return; }

        pruneRateLimit();
        if (session.actionTimestamps.length >= state.settings.autoActRateLimitPerHour) {
            toast("Auto-action saltada: limite por hora alcanzado", "error");
            session.scheduledFor = null;
            session.actedVideos.add(videoId);
            return;
        }

        const target = kind === "fav" ? findLikeBtn() : findDislikeBtn();
        const opposite = kind === "fav" ? findDislikeBtn() : findLikeBtn();
        session.scheduledFor = null;

        if (!target) { log("auto-act skip: boton no encontrado"); return; }
        if (target.getAttribute("aria-pressed") === "true") {
            log("auto-act skip: ya pulsado");
            session.actedVideos.add(videoId);
            return;
        }
        if (opposite?.getAttribute("aria-pressed") === "true") {
            log("auto-act skip: voto opuesto manual presente");
            session.actedVideos.add(videoId);
            return;
        }
        if (target.getAttribute("aria-disabled") === "true" || target.disabled) {
            log("auto-act skip: boton deshabilitado");
            session.actedVideos.add(videoId);
            return;
        }

        if (state.settings.autoActDryRun) {
            toast(`[Dry-run] ${kind === "fav" ? "Like" : "Dislike"} simulado · ${ch.name || ch.handle || ch.ucId}`);
            session.actedVideos.add(videoId);
            return;
        }

        target.click();
        session.actionTimestamps.push(Date.now());
        session.actedVideos.add(videoId);
        toast(`Auto-${kind === "fav" ? "like" : "dislike"} · ${ch.name || ch.handle || ch.ucId}`);
    }

    // ---------- CHANNEL DETECTION ----------

    function parseChannelHref(href) {
        if (!href) return { ucId: null, handle: null };
        const u = href.startsWith("/") ? "https://www.youtube.com" + href : href;
        try {
            const url = new URL(u);
            const m1 = url.pathname.match(/^\/channel\/(UC[\w-]{20,})/);
            if (m1) return { ucId: m1[1], handle: null };
            const m2 = url.pathname.match(/^\/(@[\w.\-]+)/);
            if (m2) return { ucId: null, handle: m2[1] };
        } catch {}
        return { ucId: null, handle: null };
    }

    function scrapeChannelIdFromScripts() {
        // Last resort: ytInitialPlayerResponse / ytInitialData are inlined in <script> tags.
        const scripts = document.querySelectorAll("script:not([src])");
        for (const s of scripts) {
            const t = s.textContent;
            if (!t || t.length < 100) continue;
            if (!t.includes("ytInitialPlayerResponse") && !t.includes("ytInitialData")) continue;
            const m = t.match(/"channelId":"(UC[\w-]+)"/) || t.match(/"externalChannelId":"(UC[\w-]+)"/) || t.match(/"externalId":"(UC[\w-]+)"/);
            if (m) return m[1];
        }
        return null;
    }

    function detectCurrentChannel() {
        // ---------- /shorts/<id> ----------
        // Cada Short tiene un yt-reel-channel-bar-view-model con un <a href="/@handle">
        // dentro del reel activo. La UC normalmente no esta visible en el bar; el
        // matching contra la lista usa el handle (suficiente porque los handles son
        // unicos en YouTube y storage los acepta como key).
        if (isShortsPage()) {
            const scope = getActiveShortsScope();
            const a = findVisible('yt-reel-channel-bar-view-model a[href^="/@"], yt-reel-channel-bar-view-model a[href^="/channel/"]', scope)
                   || findVisible('yt-reel-channel-bar-view-model a[href^="/@"], yt-reel-channel-bar-view-model a[href^="/channel/"]');
            const href = a?.getAttribute("href");
            const parsed = parseChannelHref(href);
            const name = a?.textContent?.trim() || null;
            if (parsed.ucId || parsed.handle) {
                return { ucId: parsed.ucId, name, handle: parsed.handle, videoId: getVideoId() };
            }
            return null;
        }
        // ---------- /watch ----------
        if (isWatchPage()) {
            const currentVideoId = getVideoId();
            const a = document.querySelector('ytd-watch-metadata ytd-channel-name a, #upload-info ytd-channel-name a, ytd-video-owner-renderer a.yt-simple-endpoint');
            const href = a?.getAttribute("href");
            const parsed = parseChannelHref(href);
            const handle = parsed.handle;
            const name = a?.textContent?.trim() || null;

            // UC sources (in order, only if trustworthy for the CURRENT video):
            //   1. IPR (only when its videoId matches the URL)
            //   2. <a href="/channel/UCxxx"> from the channel-name link (rare on /watch but unambiguous)
            // We deliberately skip <meta itemprop="channelId"> and inline-script scrape on /watch
            // because they can lag behind during SPA navigation and return the previous video's UC.
            const ipr = pageWindow.ytInitialPlayerResponse;
            const iprFresh = ipr?.videoDetails?.videoId === currentVideoId;
            let ucId = null, nameFromIPR = null;
            if (iprFresh) {
                ucId = ipr.videoDetails.channelId;
                nameFromIPR = ipr.videoDetails.author;
            } else if (parsed.ucId) {
                ucId = parsed.ucId;
            }

            if (ucId || handle) {
                return { ucId: ucId || null, name: name || nameFromIPR || null, handle, videoId: currentVideoId, _iprFresh: iprFresh };
            }
            return null;
        }
        // ---------- /channel/UCxxx ----------
        const cm = location.pathname.match(/^\/channel\/(UC[\w-]{20,})/);
        if (cm) {
            const ucId = cm[1];
            const name = document.querySelector('yt-formatted-string.ytd-channel-name, #channel-name #text, .page-header-view-model-wiz__page-header-headline-info, ytd-channel-name yt-formatted-string')?.textContent?.trim() || null;
            const handleEl = document.querySelector("#channel-handle, yt-formatted-string.page-header-view-model-wiz__page-header-byline-line");
            const handleText = handleEl?.textContent?.trim() || "";
            const handleMatch = handleText.match(/^@[\w.\-]+/);
            return { ucId, name, handle: handleMatch?.[0] || null };
        }
        // ---------- /@handle ----------
        if (location.pathname.startsWith("/@")) {
            const handle = location.pathname.split("/")[1];
            const canonical = document.querySelector('link[rel="canonical"]');
            const ucId = parseChannelHref(canonical?.href).ucId
                || scrapeChannelIdFromScripts();
            const name = document.querySelector('yt-formatted-string.ytd-channel-name, #channel-name #text, .page-header-view-model-wiz__page-header-headline-info, ytd-channel-name yt-formatted-string')?.textContent?.trim() || null;
            return { ucId: ucId || null, name, handle };
        }
        return null;
    }

    // ---------- CARD DECORATION ----------

    const CARD_SELECTORS = [
        "ytd-rich-item-renderer",
        "ytd-video-renderer",
        "ytd-compact-video-renderer",
        "ytd-grid-video-renderer",
        "ytd-rich-grid-media",
        "ytd-playlist-video-renderer",
        "ytd-reel-item-renderer",
    ].join(",");

    function decorateCard(card) {
        if (!card || card.dataset.yctChecked === "1") return;
        const channelLinks = card.querySelectorAll('a[href^="/channel/"], a[href^="/@"], ytd-channel-name a');
        let ucId = null, handle = null;
        for (const a of channelLinks) {
            const p = parseChannelHref(a.getAttribute("href"));
            if (p.ucId && !ucId) ucId = p.ucId;
            if (p.handle && !handle) handle = p.handle;
            if (ucId) break;
        }
        const kind = inList(ucId, handle);
        card.dataset.yctChecked = "1";
        if (!kind) return;
        card.classList.add("yct-card", kind === "fav" ? "yct-fav" : "yct-avoid");
        if (kind === "avoid" && state.settings.hideAvoid) {
            card.classList.add("yct-hidden");
        }
        // re-check on DOM mutation by clearing flag if channel link disappears later? Keep simple for v1.
    }

    function decorateAll(root = document) {
        root.querySelectorAll(CARD_SELECTORS).forEach(decorateCard);
    }

    function refreshAllCards() {
        document.querySelectorAll(CARD_SELECTORS).forEach(c => { delete c.dataset.yctChecked; c.classList.remove("yct-card", "yct-fav", "yct-avoid", "yct-hidden"); });
        decorateAll();
    }

    let cardObserver = null;
    function startCardObserver() {
        if (cardObserver) return;
        cardObserver = new MutationObserver(muts => {
            for (const m of muts) {
                for (const node of m.addedNodes) {
                    if (node.nodeType !== 1) continue;
                    if (node.matches?.(CARD_SELECTORS)) decorateCard(node);
                    else node.querySelectorAll?.(CARD_SELECTORS).forEach(decorateCard);
                }
            }
        });
        cardObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
    }

    // ---------- WATCH BANNER ----------

    function renderWatchBanner(retries = 0) {
        const onWatch = location.pathname.startsWith("/watch");
        const videoId = getVideoId();
        const existing = document.getElementById("yct-watch-banner");
        if (!onWatch || !state.settings.showWatchBanner) {
            existing?.remove();
            return;
        }
        // If the existing banner was rendered for a different video, drop it before re-evaluating.
        if (existing && existing.dataset.videoId !== videoId) {
            existing.remove();
        }

        const ch = detectCurrentChannel();
        if (!ch) {
            if (retries < 30) setTimeout(() => renderWatchBanner(retries + 1), 300);
            return;
        }

        const kind = inList(ch.ucId, ch.handle);
        const newKey = `${kind || "none"}|${channelKey(ch) || ""}`;
        const target = document.querySelector("ytd-watch-metadata #title, #title.ytd-watch-metadata, #above-the-fold #title");

        if (!kind) {
            document.getElementById("yct-watch-banner")?.remove();
        } else {
            if (!target) {
                if (retries < 30) setTimeout(() => renderWatchBanner(retries + 1), 300);
                return;
            }
            const current = document.getElementById("yct-watch-banner");
            if (!current || current.dataset.bannerKey !== newKey) {
                const banner = current || document.createElement("div");
                banner.id = "yct-watch-banner";
                banner.dataset.videoId = videoId;
                banner.dataset.bannerKey = newKey;
                banner.className = `yct-banner yct-banner-${kind}`;
                banner.textContent = kind === "fav"
                    ? `Canal en tu lista de favoritos — recordá darle like si te gustó.`
                    : `Canal en tu lista de "evitar".`;
                if (!current) target.parentElement?.insertBefore(banner, target);
            }
        }

        // If IPR was not fresh, give YouTube a moment to update it and re-evaluate.
        if (ch._iprFresh === false && retries < 10) {
            setTimeout(() => renderWatchBanner(retries + 1), 500);
        }
    }

    // ---------- CHANNEL ACTION PILLS (shared) ----------

    function buildChannelPills(ch, onChange) {
        const current = inList(ch.ucId, ch.handle);
        const key = channelKey(ch);
        const wrap = document.createElement("div");
        const mkBtn = (label, kind, active) => {
            const b = document.createElement("button");
            b.type = "button";
            b.className = `yct-pill ${active ? "yct-pill-active-" + kind : ""}`;
            b.textContent = label;
            b.addEventListener("click", e => {
                e.stopPropagation();
                e.preventDefault();
                if (current === kind) {
                    // Remove using whichever key is in storage (UC or handle)
                    if (ch.ucId) removeChannel(kind, ch.ucId);
                    if (ch.handle) removeChannel(kind, ch.handle);
                    toast(`Quitado de ${kind === "fav" ? "favoritos" : "evitar"}`);
                } else {
                    addChannel(kind, ch);
                    toast(`Agregado a ${kind === "fav" ? "favoritos" : "evitar"}: ${ch.name || ch.handle || ch.ucId}`);
                }
                onChange?.();
            });
            return b;
        };
        wrap.appendChild(mkBtn(current === "fav" ? "♥ Favorito" : "♡ Favorito", "fav", current === "fav"));
        wrap.appendChild(mkBtn(current === "avoid" ? "✕ Evitar" : "○ Evitar", "avoid", current === "avoid"));
        return wrap;
    }

    function refreshAllUi() {
        renderChannelPageButton();
        renderWatchActions();
        renderWatchBanner();
        renderShortsActions();
        refreshAllCards();
        maybeAutoAct();
    }

    // ---------- CHANNEL PAGE ACTION BUTTON ----------

    function renderChannelPageButton(retries = 0) {
        const onChannelPage = location.pathname.startsWith("/channel/") || location.pathname.startsWith("/@");
        const wrap = document.getElementById("yct-channel-actions");
        if (!onChannelPage) { wrap?.remove(); return; }

        const ch = detectCurrentChannel();
        // Sequential queries so the most specific host wins (querySelector with a list returns
        // the element earliest in document order, not the most specific selector).
        const host = document.querySelector("yt-flexible-actions-view-model")
                  || document.querySelector("ytd-c4-tabbed-header-renderer #inner-header-container")
                  || document.querySelector("#channel-header #inner-header-container")
                  || document.querySelector("ytd-tabbed-page-header yt-page-header-renderer")
                  || document.querySelector("page-header-view-model");
        if (!ch || !host) {
            if (retries < 30) setTimeout(() => renderChannelPageButton(retries + 1), 300);
            return;
        }

        const w = wrap || document.createElement("div");
        w.id = "yct-channel-actions";
        // Match YouTube's flexible-actions layout when the host is yt-flexible-actions-view-model
        if (host.matches?.("yt-flexible-actions-view-model")) {
            w.className = "ytFlexibleActionsViewModelAction";
        }
        w.replaceChildren(...buildChannelPills(ch, refreshAllUi).children);
        if (!wrap) host.appendChild(w);
    }

    // ---------- WATCH PAGE ACTION BUTTONS ----------

    function renderWatchActions(retries = 0) {
        const onWatch = location.pathname.startsWith("/watch");
        const videoId = getVideoId();
        const existing = document.getElementById("yct-watch-actions");
        if (!onWatch) { existing?.remove(); return; }

        // Drop stale wrap (from a previous video) before re-evaluating.
        if (existing && existing.dataset.videoId !== videoId) {
            existing.remove();
        }

        const ch = detectCurrentChannel();
        const owner = document.querySelector("ytd-watch-metadata #owner");
        if (!ch || !owner) {
            if (retries < 30) setTimeout(() => renderWatchActions(retries + 1), 300);
            return;
        }

        // Render with whatever data we have; identify what was rendered with a key so we
        // know whether to repaint when IPR upgrades from handle-only to UC.
        const renderKey = `${channelKey(ch) || ""}|${inList(ch.ucId, ch.handle) || "none"}`;
        const current = document.getElementById("yct-watch-actions");
        if (!current || current.dataset.renderKey !== renderKey) {
            const w = current || document.createElement("div");
            w.id = "yct-watch-actions";
            w.dataset.videoId = videoId;
            w.dataset.renderKey = renderKey;
            w.replaceChildren(...buildChannelPills(ch, refreshAllUi).children);
            if (!current) owner.appendChild(w);
        }

        // If IPR was not fresh, give YouTube a moment to update it and re-evaluate.
        if (ch._iprFresh === false && retries < 10) {
            setTimeout(() => renderWatchActions(retries + 1), 500);
        }
    }

    // ---------- SHORTS ACTION PILLS + STATUS BADGE ----------

    // En Shorts no hay un area "owner" estable como en /watch. Inyectamos los
    // pills (♥/✕) dentro del yt-reel-channel-bar-view-model del reel activo, y
    // marcamos el bar con clase yct-shorts-fav / yct-shorts-avoid si el canal
    // esta en alguna lista (sirve como banner visual sin ocupar espacio extra).
    function renderShortsActions(retries = 0) {
        if (!isShortsPage()) {
            document.querySelectorAll("#yct-shorts-actions").forEach(el => el.remove());
            document.querySelectorAll(".yct-shorts-fav, .yct-shorts-avoid").forEach(el => {
                el.classList.remove("yct-shorts-fav", "yct-shorts-avoid");
            });
            return;
        }

        const videoId = getVideoId();
        const scope = getActiveShortsScope();
        const bar = findVisible("yt-reel-channel-bar-view-model", scope)
                 || findVisible("yt-reel-channel-bar-view-model");
        const ch = detectCurrentChannel();
        if (!bar || !ch) {
            if (retries < 30) setTimeout(() => renderShortsActions(retries + 1), 300);
            return;
        }

        // Limpiar status visual de bars antiguos antes de pintar el activo.
        document.querySelectorAll(".yct-shorts-fav, .yct-shorts-avoid").forEach(el => {
            if (el !== bar) el.classList.remove("yct-shorts-fav", "yct-shorts-avoid");
        });

        const kind = inList(ch.ucId, ch.handle);
        bar.classList.toggle("yct-shorts-fav", kind === "fav");
        bar.classList.toggle("yct-shorts-avoid", kind === "avoid");

        const renderKey = `${channelKey(ch) || ""}|${kind || "none"}|${videoId}`;
        const existing = bar.querySelector("#yct-shorts-actions");
        if (existing && existing.dataset.renderKey === renderKey) return;

        existing?.remove();
        const w = document.createElement("div");
        w.id = "yct-shorts-actions";
        w.dataset.renderKey = renderKey;
        w.replaceChildren(...buildChannelPills(ch, refreshAllUi).children);
        bar.appendChild(w);
    }

    // ---------- MANAGER MODAL ----------

    function openManager() {
        document.getElementById("yct-modal")?.remove();
        const overlay = document.createElement("div");
        overlay.id = "yct-modal";
        setHTML(overlay, `
            <div class="yct-modal-card">
                <div class="yct-modal-head">
                    <h2>YouTube Channel Tools</h2>
                    <button type="button" class="yct-x" aria-label="Cerrar">×</button>
                </div>
                <div class="yct-tabs">
                    <button data-tab="fav" class="yct-tab yct-tab-active">Favoritos (<span data-count="fav">0</span>)</button>
                    <button data-tab="avoid" class="yct-tab">Evitar (<span data-count="avoid">0</span>)</button>
                    <button data-tab="settings" class="yct-tab">Ajustes</button>
                </div>
                <div class="yct-tab-body" data-body="fav"></div>
                <div class="yct-tab-body yct-hidden" data-body="avoid"></div>
                <div class="yct-tab-body yct-hidden" data-body="settings"></div>
                <div class="yct-modal-foot">
                    <button type="button" class="yct-btn" data-act="export">Exportar</button>
                    <button type="button" class="yct-btn" data-act="import">Importar</button>
                </div>
            </div>
        `);
        document.body.appendChild(overlay);
        overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
        overlay.querySelector(".yct-x").addEventListener("click", () => overlay.remove());
        overlay.querySelectorAll(".yct-tab").forEach(t => {
            t.addEventListener("click", () => {
                overlay.querySelectorAll(".yct-tab").forEach(x => x.classList.remove("yct-tab-active"));
                t.classList.add("yct-tab-active");
                const which = t.dataset.tab;
                overlay.querySelectorAll(".yct-tab-body").forEach(b => b.classList.toggle("yct-hidden", b.dataset.body !== which));
            });
        });
        overlay.querySelector('[data-act="export"]').addEventListener("click", () => {
            const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" });
            const url = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = url; a.download = "yct-export.json"; a.click();
            setTimeout(() => URL.revokeObjectURL(url), 5000);
        });
        overlay.querySelector('[data-act="import"]').addEventListener("click", () => {
            const input = document.createElement("input");
            input.type = "file"; input.accept = "application/json";
            input.addEventListener("change", () => {
                const f = input.files?.[0]; if (!f) return;
                const r = new FileReader();
                r.onload = () => {
                    try {
                        const parsed = JSON.parse(r.result);
                        if (parsed.favorites) state.favorites = parsed.favorites;
                        if (parsed.avoid) state.avoid = parsed.avoid;
                        if (parsed.settings) Object.assign(state.settings, parsed.settings);
                        saveState();
                        renderManagerLists(overlay);
                        refreshAllUi();
                        toast("Importado");
                    } catch (e) { toast("Archivo invalido", "error"); }
                };
                r.readAsText(f);
            });
            input.click();
        });
        renderManagerLists(overlay);
        renderManagerSettings(overlay);
    }

    function renderManagerLists(overlay) {
        for (const kind of ["fav", "avoid"]) {
            const body = overlay.querySelector(`[data-body="${kind}"]`);
            const list = listFor(kind);
            const entries = Object.values(list).sort((a, b) => (a.name || a.handle || a.ucId).localeCompare(b.name || b.handle || b.ucId));
            overlay.querySelector(`[data-count="${kind}"]`).textContent = entries.length;
            if (!entries.length) setHTML(body, `<p class="yct-empty">Sin canales. Agregalos desde la pagina del canal.</p>`);
            else body.replaceChildren();
            for (const e of entries) {
                const row = document.createElement("div");
                row.className = "yct-row";
                const key = e.ucId || e.handle;
                setHTML(row, `
                    <div class="yct-row-info">
                        <strong>${escapeHtml(e.name || e.handle || e.ucId || "?")}</strong>
                        <small>${escapeHtml(e.handle || "")} ${escapeHtml(e.ucId || "")}</small>
                    </div>
                    <button type="button" class="yct-btn yct-btn-danger">Quitar</button>
                `);
                row.querySelector("button").addEventListener("click", () => {
                    removeChannel(kind, key);
                    renderManagerLists(overlay);
                    refreshAllUi();
                    toast(`Quitado: ${e.name || key}`);
                });
                body.appendChild(row);
            }
        }
    }

    function renderManagerSettings(overlay) {
        const body = overlay.querySelector('[data-body="settings"]');
        setHTML(body, `
            <h3 class="yct-section">Visual</h3>
            <label class="yct-opt"><input type="checkbox" data-set="showFab"> Mostrar boton flotante para abrir el gestor</label>
            <label class="yct-opt"><input type="checkbox" data-set="hideAvoid"> Ocultar videos de canales en la lista "evitar"</label>
            <label class="yct-opt"><input type="checkbox" data-set="showWatchBanner"> Mostrar aviso en pagina del video</label>

            <h3 class="yct-section">Auto-acciones (riesgo)</h3>
            <div class="yct-warn">
                <strong>⚠ Lee antes de activar.</strong>
                Habilitar esto hace que el script clickee like / dislike por vos en videos de las listas. YouTube prohibe interacciones automatizadas en su ToS, y aunque los guardarraíles bajan el riesgo, no lo eliminan: el peor caso reportado es <em>shadow-throttle</em> (tus likes no cuentan); el peor teorico es suspension de la cuenta. Probalo primero en modo simulado.
            </div>
            <label class="yct-opt"><input type="checkbox" data-set="autoActEnabled"> Habilitar auto-like / auto-dislike por canal</label>
            <label class="yct-opt"><input type="checkbox" data-set="autoActDryRun"> Modo simulado (loguea y muestra toast, no clickea)</label>
            <label class="yct-opt yct-opt-num">
                Limite por hora
                <input type="number" min="1" max="60" data-set-num="autoActRateLimitPerHour">
            </label>
            <label class="yct-opt yct-opt-num">
                Tiempo minimo de reproduccion (s)
                <input type="number" min="1" max="600" data-set-num="autoActMinWatchSec">
            </label>
            <label class="yct-opt yct-opt-num">
                Delay aleatorio min (s)
                <input type="number" min="0" max="120" data-set-num="autoActDelayMinSec">
            </label>
            <label class="yct-opt yct-opt-num">
                Delay aleatorio max (s)
                <input type="number" min="0" max="600" data-set-num="autoActDelayMaxSec">
            </label>

            <p class="yct-hint">
                Guardarraíles activos: requiere sesion iniciada · skip si ya esta pulsado · skip si el voto opuesto fue puesto por vos · skip si el boton esta deshabilitado · una sola vez por video por sesion · solo cuenta tiempo de reproduccion real (no pausado, no seek).
            </p>

            <h3 class="yct-section">Estado actual</h3>
            <div class="yct-diag" data-diag>—</div>
            <button type="button" class="yct-btn" data-act="diag-refresh">Refrescar diagnóstico</button>

            <p class="yct-hint">Versión ${SCRIPT_VERSION}. Conteo de dislikes vía API publica de Return YouTube Dislike. Sin envío de votos hacia esa API.</p>
        `);
        const diagEl = body.querySelector("[data-diag]");
        const updateDiag = () => { if (diagEl) diagEl.textContent = diagnoseAutoAct(); };
        updateDiag();
        body.querySelector('[data-act="diag-refresh"]')?.addEventListener("click", updateDiag);
        body.querySelectorAll("input[data-set]").forEach(cb => {
            const key = cb.dataset.set;
            cb.checked = !!state.settings[key];
            cb.addEventListener("change", () => {
                state.settings[key] = cb.checked;
                saveState();
                refreshAllUi();
                if (key === "showFab") injectFab();
                if (key === "autoActEnabled") {
                    toast(diagnoseAutoAct(), "info", 5000);
                    updateDiag();
                }
            });
        });
        body.querySelectorAll("input[data-set-num]").forEach(inp => {
            const key = inp.dataset.setNum;
            inp.value = state.settings[key];
            inp.addEventListener("change", () => {
                const n = parseInt(inp.value, 10);
                if (!isFinite(n) || n < 0) { inp.value = state.settings[key]; return; }
                state.settings[key] = n;
                saveState();
            });
        });
    }

    function escapeHtml(s) {
        return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
    }

    // ---------- STYLES ----------

    function injectStyles() {
        GM_addStyle(`
            #yct-fab { position: fixed; bottom: 20px; left: 20px; width: 36px; height: 36px; border-radius: 50%; border: 1px solid #444; background: rgba(34,34,34,.85); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 2147483645; box-shadow: 0 2px 8px rgba(0,0,0,.4); opacity: .55; transition: opacity .15s, transform .15s; padding: 0; }
            #yct-fab:hover { opacity: 1; transform: scale(1.08); }
            #yct-toast-wrap { position: fixed; right: 20px; bottom: 20px; z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
            .yct-toast { background: #222; color: #fff; padding: 10px 14px; border-radius: 6px; font: 13px sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,.3); opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; pointer-events: auto; max-width: 360px; }
            .yct-toast-in { opacity: 1; transform: translateY(0); }
            .yct-toast-error { background: #b3261e; }

            .yct-card.yct-fav { outline: 2px solid #4caf50; outline-offset: -2px; border-radius: 12px; }
            .yct-card.yct-avoid { outline: 2px solid #c62828; outline-offset: -2px; border-radius: 12px; opacity: .7; }
            .yct-card.yct-fav::before, .yct-card.yct-avoid::before {
                content: ""; position: absolute; top: 6px; left: 6px; width: 22px; height: 22px; border-radius: 50%; z-index: 5;
                background-size: 14px 14px; background-position: center; background-repeat: no-repeat;
            }
            .yct-card.yct-fav::before { background-color: #4caf50; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M12 21s-7-4.5-9.5-9.1C.7 8.6 2.4 5 6 5c2 0 3.3 1 4 2 .7-1 2-2 4-2 3.6 0 5.3 3.6 3.5 6.9C19 16.5 12 21 12 21z'/></svg>"); }
            .yct-card.yct-avoid::before { background-color: #c62828; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M19 6.4L17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z'/></svg>"); }
            .yct-card { position: relative; }
            .yct-hidden { display: none !important; }

            .yct-banner { padding: 10px 14px; border-radius: 8px; font: 500 13px/1.4 sans-serif !important; margin-bottom: 10px; color: #fff !important; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
            .yct-banner-fav { background: #2e7d32 !important; border-left: 4px solid #81c784; }
            .yct-banner-avoid { background: #c62828 !important; border-left: 4px solid #ef5350; }

            #yct-channel-actions { display: inline-flex; gap: 8px; align-items: center; }
            #yct-watch-actions { display: inline-flex; gap: 8px; margin-left: 12px; align-items: center; }
            .yct-pill { background: var(--yt-spec-badge-chip-background, #303030); color: var(--yt-spec-text-primary, #fff); border: 1px solid transparent; border-radius: 18px; padding: 6px 14px; font: 13px sans-serif; cursor: pointer; }
            .yct-pill:hover { background: var(--yt-spec-button-chip-background-hover, #3f3f3f); }
            .yct-pill-active-fav { background: #4caf50; color: #fff; border-color: #4caf50; }
            .yct-pill-active-avoid { background: #c62828; color: #fff; border-color: #c62828; }

            /* Shorts: pills compactos pegados al channel-bar */
            #yct-shorts-actions { display: inline-flex; gap: 6px; margin-left: 8px; vertical-align: middle; }
            #yct-shorts-actions .yct-pill { padding: 3px 10px; font-size: 11px; border-radius: 14px; }
            /* Indicador de favorito / evitar sobre el bar del canal del Short */
            yt-reel-channel-bar-view-model.yct-shorts-fav { box-shadow: inset 4px 0 0 #4caf50; padding-left: 6px; border-radius: 4px; }
            yt-reel-channel-bar-view-model.yct-shorts-avoid { box-shadow: inset 4px 0 0 #c62828; padding-left: 6px; border-radius: 4px; }

            #yct-modal { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 2147483646; display: flex; align-items: center; justify-content: center; }
            .yct-modal-card { background: #1f1f1f; color: #fff; border-radius: 12px; width: min(560px, 92vw); max-height: 86vh; display: flex; flex-direction: column; font: 14px sans-serif; }
            .yct-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid #333; }
            .yct-modal-head h2 { margin: 0; font-size: 16px; font-weight: 600; }
            .yct-x { background: transparent; border: none; color: #fff; font-size: 22px; cursor: pointer; line-height: 1; }
            .yct-tabs { display: flex; gap: 0; border-bottom: 1px solid #333; }
            .yct-tab { flex: 1; background: transparent; border: none; color: #bbb; padding: 10px; cursor: pointer; font: inherit; border-bottom: 2px solid transparent; }
            .yct-tab-active { color: #fff; border-bottom-color: #4caf50; }
            .yct-tab-body { padding: 14px 18px; overflow-y: auto; flex: 1; }
            .yct-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #2a2a2a; gap: 12px; }
            .yct-row:last-child { border-bottom: none; }
            .yct-row-info strong { display: block; }
            .yct-row-info small { color: #888; font-size: 11px; word-break: break-all; }
            .yct-empty { color: #888; text-align: center; padding: 20px; }
            .yct-opt { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; }
            .yct-opt-num { justify-content: space-between; }
            .yct-opt-num input[type="number"] { width: 80px; background: #2a2a2a; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 4px 6px; font: inherit; }
            .yct-section { margin: 16px 0 4px; font-size: 13px; color: #ccc; text-transform: uppercase; letter-spacing: .5px; }
            .yct-warn { background: rgba(198,40,40,.12); border-left: 3px solid #c62828; padding: 10px 12px; border-radius: 4px; font-size: 13px; line-height: 1.4; margin: 8px 0 12px; }
            .yct-warn strong { color: #ff8a80; }
            .yct-hint { color: #888; font-size: 12px; margin-top: 12px; line-height: 1.4; }
            .yct-diag { background: #2a2a2a; border-left: 3px solid #4caf50; padding: 8px 12px; border-radius: 4px; font-size: 13px; margin: 4px 0 8px; font-family: monospace; }
            .yct-btn { background: #2a2a2a; color: #fff; border: 1px solid #444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font: inherit; }
            .yct-btn:hover { background: #383838; }
            .yct-btn-danger { background: transparent; border-color: #c62828; color: #c62828; }
            .yct-btn-danger:hover { background: rgba(198,40,40,.15); }
            .yct-modal-foot { padding: 12px 18px; border-top: 1px solid #333; display: flex; gap: 8px; justify-content: flex-end; }
        `);
    }

    // ---------- FLOATING BUTTON ----------

    function injectFab() {
        if (!state.settings.showFab) {
            document.getElementById("yct-fab")?.remove();
            return;
        }
        if (document.getElementById("yct-fab")) return;
        const fab = document.createElement("button");
        fab.id = "yct-fab";
        fab.type = "button";
        fab.title = "YouTube Channel Tools";
        fab.setAttribute("aria-label", "Abrir gestor de canales");
        setHTML(fab, `<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M19.43 13c.04-.33.07-.66.07-1s-.03-.67-.07-1l2.11-1.65a.5.5 0 00.12-.64l-2-3.46a.5.5 0 00-.6-.22l-2.49 1a7.3 7.3 0 00-1.73-1L14.5 2.42a.5.5 0 00-.5-.42h-4a.5.5 0 00-.5.42l-.31 2.61c-.62.25-1.2.59-1.73 1l-2.49-1a.5.5 0 00-.6.22l-2 3.46a.5.5 0 00.12.64L4.6 11c-.04.33-.07.66-.07 1s.03.67.07 1l-2.11 1.65a.5.5 0 00-.12.64l2 3.46c.14.24.43.34.69.22l2.49-1c.53.41 1.11.75 1.73 1l.31 2.61c.05.24.27.42.5.42h4c.23 0 .45-.18.5-.42l.31-2.61c.62-.25 1.2-.59 1.73-1l2.49 1a.5.5 0 00.6-.22l2-3.46a.5.5 0 00-.12-.64L19.43 13zM12 15.5A3.5 3.5 0 1115.5 12 3.5 3.5 0 0112 15.5z"/></svg>`);
        fab.addEventListener("click", openManager);
        (document.body || document.documentElement).appendChild(fab);
    }

    // ---------- BOOT ----------

    function update() {
        updateDislike();
        renderWatchBanner();
        renderChannelPageButton();
        renderWatchActions();
        renderShortsActions();
        decorateAll();
        injectFab();
        maybeAutoAct();
    }

    function boot() {
        loadState();
        injectStyles();
        startCardObserver();
        decorateAll();
        renderWatchBanner();
        renderChannelPageButton();
        renderWatchActions();
        renderShortsActions();
        injectFab();
        updateDislike();
        setInterval(tickPlayback, 1000);
        maybeAutoAct();

        window.addEventListener("yt-navigate-finish", update, true);

        // Shorts cambia de video por scroll-snap sin disparar yt-navigate-finish
        // de forma fiable. Polling ligero del URL para reaccionar al cambio.
        let lastUrl = location.href;
        setInterval(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                update();
            }
        }, 500);

        try { GM_registerMenuCommand("Abrir gestor de canales", openManager); } catch {}

        log("v" + SCRIPT_VERSION + " cargado", { fav: Object.keys(state.favorites).length, avoid: Object.keys(state.avoid).length });
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", boot, { once: true });
    } else {
        boot();
    }
})();