Raw Source
g31w0fw0rldgmail.com / YouTube Channel Tools

// ==UserScript==
// @name         YouTube Channel Tools
// @namespace    http://tampermonkey.net/
// @version      1.1.6
// @description  Favourite and avoid lists for YouTube channels. YouTube gives you subscribed or nothing: no way to mark a channel you merely like, none to bury one you never want to see again, and the dislike count is gone. This adds both lists —on videos, on Shorts and on mobile— and brings that count back. Auto-like and auto-dislike exist, ship off and stay behind guardrails. USE AT YOUR OWN RISK: YouTube forbids automated interactions. More in "Learn more".
// @icon         data:image/png;base64,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
// @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.1.6";
    const pageWindow = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
    const STORAGE_KEY = "yct-state";
    const log = (...a) => console.log("[YT Channel Tools]", ...a);

    // ---------- IDIOMA ----------
    // Solo distingue español vs. inglés (inglés por defecto), como el resto del
    // catálogo. Aquí se prioriza el lang del documento y NO navigator.languages:
    // YouTube fija documentElement.lang al idioma que el usuario eligió en su
    // cuenta, así que es la señal más fiel a lo que está viendo. Ojo, es el caso
    // CONTRARIO al de IndieGala, que clava su propio lang ignorando al usuario y
    // por eso allí se detecta por navegador; no confundir uno con otro.
    function detectLang() {
        const docLang = (document.documentElement.getAttribute("lang") || "").toLowerCase();
        const navLang = (navigator.language || navigator.languages?.[0] || "").toLowerCase();
        return (docLang || navLang).startsWith("es") ? "es" : "en";
    }

    // El texto del enlace es la URL, no la palabra: en la ficha de "Saber más" la
    // columna de valores son direcciones, y "Ko-fi" ahí no diría a dónde lleva.
    const KOFI = '<a href="https://ko-fi.com/g31w0fw0rld" target="_blank" rel="noopener">ko-fi.com/g31w0fw0rld</a>';
    const REPO = '<a href="https://github.com/g31w0fw0rld/youtube-channel-tools" target="_blank" rel="noopener">github.com/g31w0fw0rld/youtube-channel-tools</a>';

    const I18N = {
        es: {
            dislikePill: "Consultar el conteo de dislikes en Return YouTube Dislike (envía el ID de este video)",
            diagOff: "Auto-acción: desactivada en Ajustes",
            diagNotVideo: "Auto-acción: no estás en /watch ni /shorts",
            diagNoVideoId: "Auto-acción: sin videoId",
            diagNoChannel: "Auto-acción: canal del video no detectado",
            diagNotListed: (name) => `Auto-acción: ${name} no está en ninguna lista`,
            diagNoSession: "Auto-acción: sesión no detectada (¿has iniciado sesión?)",
            diagAlreadyActed: "Auto-acción: ya actuó en este video (esta sesión)",
            diagNoButton: "Auto-acción: aquí no hay botón que pulsar (YouTube retiró el dislike de los Shorts en junio de 2026)",
            diagWaiting: (played, min) => `Auto-acción: esperando tiempo de reproducción ${played}s / ${min}s`,
            diagScheduled: (dMax) => `Auto-acción: programada, esperando retardo (máx ${dMax}s)`,
            diagReady: (isFav) => `Auto-acción: lista para ${isFav ? "like" : "dislike"} en los próximos segundos`,
            rateLimited: "Auto-acción omitida: límite por hora alcanzado",
            dryRun: (isFav, name) => `[Simulado] ${isFav ? "Like" : "Dislike"} · ${name}`,
            acted: (isFav, name) => `Auto-${isFav ? "like" : "dislike"} · ${name}`,
            bannerFav: "Canal en tu lista de favoritos — recuerda darle like si te gustó.",
            bannerAvoid: "Canal en tu lista de «evitar».",
            pillFav: (on) => (on ? "♥ Favorito" : "♡ Favorito"),
            pillAvoid: (on) => (on ? "✕ Evitar" : "○ Evitar"),
            removedFrom: (isFav) => `Quitado de ${isFav ? "favoritos" : "evitar"}`,
            addedTo: (isFav, name) => `Agregado a ${isFav ? "favoritos" : "evitar"}: ${name}`,
            close: "Cerrar",
            openManager: "Abrir gestor de canales",
            tabFav: "Favoritos",
            tabAvoid: "Evitar",
            tabSettings: "Ajustes",
            tabAbout: "Saber más",
            doExport: "Exportar",
            doImport: "Importar",
            imported: "Importado",
            invalidFile: "Archivo inválido",
            emptyList: "Sin canales. Agrégalos desde la página del canal.",
            remove: "Quitar",
            removed: (name) => `Quitado: ${name}`,
            secVisual: "Visual",
            optFab: "Mostrar botón flotante para abrir el gestor",
            optHideAvoid: "Ocultar videos de canales en la lista «evitar»",
            optBanner: "Mostrar aviso en la página del video",
            secPrivacy: "Privacidad",
            dislikeCount: "Conteo de dislikes",
            modeAuto: "Automático (consulta al abrir el video)",
            modeOnDemand: "Solo si lo pido (botón 👎 ?)",
            modeOff: "Desactivado (nunca usa la red)",
            privacyHint: "El conteo lo provee la API pública de Return YouTube Dislike: YouTube no publica ese dato desde 2021, así que no hay forma de calcularlo localmente. Cada consulta le revela a RYD el ID del video que estás viendo y tu IP (nunca tus listas, tus ajustes ni tus votos, y la petición va sin cookies). En «solo si lo pido» no sale ninguna petición hasta que hagas clic; en «desactivado» el script queda 100% local.",
            dislikeOffToast: "Conteo de dislikes desactivado. Recarga para limpiar el número ya visible.",
            secAuto: "Auto-acciones (riesgo)",
            warnTitle: "⚠ Lee antes de activar.",
            warnBody: "Habilitar esto hace que el script haga clic en like / dislike por ti en los videos de las listas. YouTube prohíbe las interacciones automatizadas en sus condiciones de servicio, y aunque los guardarraíles bajan el riesgo, no lo eliminan: el peor caso reportado es <em>shadow-throttle</em> (tus likes dejan de contar); el peor teórico es la suspensión de la cuenta. Pruébalo primero en modo simulado.",
            optAutoEnabled: "Habilitar auto-like / auto-dislike por canal",
            optDryRun: "Modo simulado (registra y avisa, no hace clic)",
            numRateLimit: "Límite por hora",
            numMinWatch: "Tiempo mínimo de reproducción (s)",
            numDelayMin: "Retardo aleatorio mín (s)",
            numDelayMax: "Retardo aleatorio máx (s)",
            guardrailsHint: "Guardarraíles activos: requiere sesión iniciada · omite si ya está pulsado · omite si el voto opuesto lo pusiste tú · omite si el botón está deshabilitado · una sola vez por video y sesión · solo cuenta tiempo de reproducción real (no pausado, no adelantado).",
            secStatus: "Estado actual",
            refreshDiag: "Refrescar diagnóstico",
            settingsFoot: (v) => `Versión ${v}. Conteo de dislikes vía API pública de Return YouTube Dislike. Sin envío de votos hacia esa API.`,
            aboutTitle: "¿Qué hace este script?",
            aboutName: 'Nombre:',
            aboutVersion: 'Versión:',
            aboutAuthor: 'Autor:',
            aboutIntro: "YouTube Channel Tools organiza canales en dos listas —<strong>Favoritos</strong> y <strong>Evitar</strong>— y actúa sobre ellas mientras navegas:",
            aboutItems: [
                "<strong>Favoritos / Evitar:</strong> marca canales desde su página o desde los videos; se guardan localmente en tu navegador.",
                "<strong>Videos, Shorts y móvil:</strong> los botones aparecen igual en /watch, en /shorts —con su propia barra— y en m.youtube.com.",
                "<strong>Ocultar «evitar»:</strong> quita de tu feed los videos de los canales que evitas. No alcanza a las miniaturas de Shorts: YouTube no incluye el canal en ellas, así que no hay con qué reconocerlas.",
                "<strong>Aviso en el video:</strong> muestra un banner cuando el video es de un canal marcado (favorito o a evitar).",
                "<strong>Conteo de dislikes:</strong> se muestra en /watch usando la API pública de Return YouTube Dislike (solo lectura; el script no envía tus votos a esa API). En Shorts no: YouTube retiró ese botón en junio de 2026, así que ahí no hay dónde ponerlo y el script ni pregunta. Lo único que sale de tu navegador es el ID del video que estás viendo, para preguntar su conteo; tus listas, tus ajustes y tu identidad no se envían, y la petición va sin cookies. En <em>Ajustes → Privacidad</em> puedes pasarlo a «solo si lo pido» o apagarlo del todo.",
                "<strong>Auto-acciones (opcional, riesgo):</strong> puede dar like/dislike por ti en canales de tus listas, con guardarraíles y modo simulado. ⚠️ YouTube prohíbe las interacciones automatizadas; úsalo bajo tu propio riesgo.",
                "<strong>Ritmo ajustable:</strong> límite por hora, tiempo mínimo de reproducción y retardo aleatorio (mín/máx) se configuran en Ajustes; nada de eso se dispara si la auto-acción está apagada.",
                "<strong>Estado actual:</strong> un diagnóstico dentro de Ajustes que dice, en palabras, por qué la auto-acción actuó o por qué no lo hizo.",
                "<strong>Exportar / Importar:</strong> respalda o restaura tus listas y ajustes en un archivo JSON.",
                "<strong>Botón flotante:</strong> abre este gestor desde cualquier página de YouTube. Se puede ocultar desde Ajustes.",
            ],
            aboutClosing: "Todo se procesa en tu navegador; tus listas no salen de tu equipo.",
        },
        en: {
            dislikePill: "Look up the dislike count on Return YouTube Dislike (sends this video's ID)",
            diagOff: "Auto-action: disabled in Settings",
            diagNotVideo: "Auto-action: you are not on /watch or /shorts",
            diagNoVideoId: "Auto-action: no videoId",
            diagNoChannel: "Auto-action: video channel not detected",
            diagNotListed: (name) => `Auto-action: ${name} is not on any list`,
            diagNoSession: "Auto-action: no session detected (are you signed in?)",
            diagAlreadyActed: "Auto-action: already acted on this video (this session)",
            diagNoButton: "Auto-action: there is no button to press here (YouTube retired the dislike on Shorts in June 2026)",
            diagWaiting: (played, min) => `Auto-action: waiting for watch time ${played}s / ${min}s`,
            diagScheduled: (dMax) => `Auto-action: scheduled, waiting for delay (max ${dMax}s)`,
            diagReady: (isFav) => `Auto-action: ready to ${isFav ? "like" : "dislike"} in the next few seconds`,
            rateLimited: "Auto-action skipped: hourly limit reached",
            dryRun: (isFav, name) => `[Dry-run] ${isFav ? "Like" : "Dislike"} · ${name}`,
            acted: (isFav, name) => `Auto-${isFav ? "like" : "dislike"} · ${name}`,
            bannerFav: "Channel on your favorites list — remember to like it if you enjoyed it.",
            bannerAvoid: "Channel on your “avoid” list.",
            pillFav: (on) => (on ? "♥ Favorite" : "♡ Favorite"),
            pillAvoid: (on) => (on ? "✕ Avoid" : "○ Avoid"),
            removedFrom: (isFav) => `Removed from ${isFav ? "favorites" : "avoid"}`,
            addedTo: (isFav, name) => `Added to ${isFav ? "favorites" : "avoid"}: ${name}`,
            close: "Close",
            openManager: "Open channel manager",
            tabFav: "Favorites",
            tabAvoid: "Avoid",
            tabSettings: "Settings",
            tabAbout: "Learn more",
            doExport: "Export",
            doImport: "Import",
            imported: "Imported",
            invalidFile: "Invalid file",
            emptyList: "No channels yet. Add them from the channel page.",
            remove: "Remove",
            removed: (name) => `Removed: ${name}`,
            secVisual: "Visual",
            optFab: "Show floating button to open the manager",
            optHideAvoid: "Hide videos from channels on the “avoid” list",
            optBanner: "Show notice on the video page",
            secPrivacy: "Privacy",
            dislikeCount: "Dislike count",
            modeAuto: "Automatic (looks it up when the video opens)",
            modeOnDemand: "Only on demand (👎 ? button)",
            modeOff: "Disabled (never touches the network)",
            privacyHint: "The count comes from the public Return YouTube Dislike API: YouTube stopped publishing this figure in 2021, so there is no way to compute it locally. Each lookup reveals to RYD the ID of the video you are watching and your IP (never your lists, your settings or your votes, and the request is sent without cookies). On “only on demand” no request is made until you click; on “disabled” the script stays 100% local.",
            dislikeOffToast: "Dislike count disabled. Reload to clear the number already on screen.",
            secAuto: "Auto-actions (risk)",
            warnTitle: "⚠ Read before enabling.",
            warnBody: "Enabling this makes the script click like / dislike for you on videos from your lists. YouTube forbids automated interactions in its Terms of Service, and although the guardrails lower the risk, they do not remove it: the worst reported case is <em>shadow-throttling</em> (your likes stop counting); the worst theoretical one is account suspension. Try it in simulated mode first.",
            optAutoEnabled: "Enable auto-like / auto-dislike per channel",
            optDryRun: "Simulated mode (logs and notifies, does not click)",
            numRateLimit: "Hourly limit",
            numMinWatch: "Minimum playback time (s)",
            numDelayMin: "Random delay min (s)",
            numDelayMax: "Random delay max (s)",
            guardrailsHint: "Active guardrails: requires an active session · skips if already pressed · skips if you cast the opposite vote yourself · skips if the button is disabled · once per video per session · only counts real playback time (not paused, not seeking).",
            secStatus: "Current status",
            refreshDiag: "Refresh diagnostics",
            settingsFoot: (v) => `Version ${v}. Dislike count via the public Return YouTube Dislike API. No votes are sent to that API.`,
            aboutTitle: "What does this script do?",
            aboutName: 'Name:',
            aboutVersion: 'Version:',
            aboutAuthor: 'Author:',
            aboutIntro: "YouTube Channel Tools organizes channels into two lists —<strong>Favorites</strong> and <strong>Avoid</strong>— and acts on them as you browse:",
            aboutItems: [
                "<strong>Favorites / Avoid:</strong> mark channels from their page or from videos; they are stored locally in your browser.",
                "<strong>Videos, Shorts and mobile:</strong> the buttons show up the same on /watch, on /shorts —with their own bar— and on m.youtube.com.",
                "<strong>Hide “avoid”:</strong> removes videos from channels you avoid out of your feed. Not Shorts thumbnails, though: YouTube does not include the channel in them, so there is nothing to match them by.",
                "<strong>Video notice:</strong> shows a banner when the video comes from a marked channel (favorite or avoided).",
                "<strong>Dislike count:</strong> shown on /watch using the public Return YouTube Dislike API (read-only; the script never sends your votes to that API). Not on Shorts: YouTube retired that button in June 2026, so there is nowhere to put it and the script does not even ask. The only thing that leaves your browser is the ID of the video you are watching, to ask for its count; your lists, your settings and your identity are not sent, and the request goes without cookies. Under <em>Settings → Privacy</em> you can switch it to “only on demand” or turn it off entirely.",
                "<strong>Auto-actions (optional, risky):</strong> can like/dislike for you on channels from your lists, with guardrails and a simulated mode. ⚠️ YouTube forbids automated interactions; use at your own risk.",
                "<strong>Tunable pacing:</strong> hourly limit, minimum playback time and random delay (min/max) are all set in Settings; none of it fires while auto-action is off.",
                "<strong>Current status:</strong> a diagnostic inside Settings that tells you, in words, why the auto-action acted or why it did not.",
                "<strong>Export / Import:</strong> back up or restore your lists and settings as a JSON file.",
                "<strong>Floating button:</strong> opens this manager from any YouTube page. It can be hidden from Settings.",
            ],
            aboutClosing: "Everything runs in your browser; your lists never leave your machine.",
        },
    };

    // Se resuelve en boot(), no aquí: el script corre en document-start y en ese
    // momento <html> puede no tener todavía el atributo lang, así que detectar
    // ahora daría inglés siempre. Toda la UI se construye después de boot().
    let t = I18N.en;

    // ---------- 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: {
            // "auto" = pide el conteo al abrir el video (comportamiento historico),
            // "ondemand" = solo al hacer clic en el boton, "off" = nunca toca la red.
            dislikeMode: "auto",
            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,
                // anonymous: el endpoint no pide autenticacion, asi que mandamos la
                // peticion sin cookies para que RYD no pueda hilar consultas en una
                // sesion. Los gestores que no lo soporten ignoran la propiedad.
                anonymous: true,
                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;
    }

    // YouTube retiro el boton de dislike de Shorts en junio de 2026 (despliegue
    // escalonado: en algunas regiones sigue ahi). Sin boton no hay donde pintar
    // el conteo, asi que esperamos a que aparezca ANTES de preguntarle a RYD: de
    // otro modo cada Short que pasa manda su ID a un tercero a cambio de nada.
    function waitForDislikeBtn(videoId) {
        return new Promise(resolve => {
            let tries = 0;
            const tick = () => {
                if (watchVideoId !== videoId || dislikeKilled) return resolve(false);
                if (findDislikeBtn()) return resolve(true);
                if (++tries >= 12) return resolve(false);
                setTimeout(tick, 250);
            };
            tick();
        });
    }

    // El boton de dislike de /watch ya no trae span de texto propio (YouTube dejo
    // de publicar el numero en 2021), asi que el nuestro se inyecta y lleva la
    // clase kebab del shape system para heredar el formato. Pero YouTube esta
    // migrando ese sistema a camelCase —el mismo boton ya trae
    // ytSpecButtonShapeNextIcon—, asi que el dia que retiren el CSS viejo el
    // numero se quedaria sin formato. Copiamos la tipografia del contador de
    // likes, que es la que tendria este, buscandolo por su contenido y no por
    // ninguna clase: si no aparece, no tocamos nada y queda como antes.
    function copyCountFont(node) {
        const src = [...(findLikeBtn()?.querySelectorAll("span") || [])].find(e => /\d/.test(e.textContent || ""));
        if (!src) return;
        const cs = getComputedStyle(src);
        for (const prop of ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing"]) {
            if (cs[prop]) node.style[prop] = cs[prop];
        }
    }

    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';
                copyCountFont(node);
                btn.appendChild(node);
            }
        }
        if (node.dataset.yctApplied === text) return true;
        node.textContent = text;
        node.dataset.yctApplied = text;
        return true;
    }

    // Pinta el conteo con reintentos. 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.
    function paintDislikePersistently(videoId, text) {
        let tries = 0;
        const tick = () => {
            if (watchVideoId !== videoId || dislikeKilled) return;
            if (paintDislike(text)) return;
            if (++tries < 30) setTimeout(tick, 250);
        };
        tick();
        [600, 1500, 3000, 5000].forEach(ms => {
            setTimeout(() => {
                if (watchVideoId === videoId && !dislikeKilled) paintDislike(text);
            }, ms);
        });
    }

    function removeDislikeTrigger() {
        document.querySelectorAll(".yct-dislike-trigger").forEach(el => el.remove());
    }

    // Ancla de la pildora: colgarla del padre inmediato del <button> no sirve,
    // porque ese es justo el nodo que YouTube reemplaza al re-renderizar. Subimos
    // al *-view-model que agrupa like+dislike (o al <label> en Shorts), que vive
    // dentro de la fila de acciones y sobrevive mejor.
    function findTriggerAnchor() {
        const btn = findDislikeBtn();
        if (!btn) return null;
        const anchor = btn.closest("segmented-like-dislike-button-view-model")
                    || btn.closest("dislike-button-view-model")
                    || btn.closest("label")
                    || btn.parentElement;
        return anchor?.parentElement ? anchor : null;
    }

    // Modo on-demand: en vez de preguntarle a RYD al abrir el video, dejamos un
    // boton propio (fuera del boton de dislike de YouTube, para no votar por
    // accidente) que dispara la consulta solo si el usuario la pide.
    // Devuelve true si la pildora quedo en su lugar (o ya estaba).
    function insertDislikeTrigger(videoId) {
        if (state.settings.dislikeMode !== "ondemand") return true;
        // Si ya tenemos el conteo (el usuario lo pidio), no reponer la pildora:
        // los repintados diferidos siguen corriendo despues del clic.
        const hit = dislikeCache.get(videoId);
        if (hit && Date.now() - hit.ts < CACHE_TTL) return true;
        const anchor = findTriggerAnchor();
        if (!anchor) return false;
        const parent = anchor.parentElement;
        if (parent.querySelector(":scope > .yct-dislike-trigger")) return true;
        const pill = document.createElement("button");
        pill.type = "button";
        pill.className = "yct-dislike-trigger";
        pill.textContent = "👎 ?";
        pill.title = t.dislikePill;
        pill.addEventListener("click", ev => {
            ev.preventDefault();
            ev.stopPropagation();
            removeDislikeTrigger();
            updateDislike(true);
        });
        parent.insertBefore(pill, anchor.nextSibling);
        return true;
    }

    function paintDislikeTrigger(videoId) {
        let tries = 0;
        const tick = () => {
            if (watchVideoId !== videoId || dislikeKilled) return;
            if (state.settings.dislikeMode !== "ondemand") return;
            if (insertDislikeTrigger(videoId)) return;
            if (++tries < 30) setTimeout(tick, 250);
            else log("on-demand: no se encontro donde anclar el boton de dislikes");
        };
        tick();
        // Mismo motivo que paintDislikePersistently: YouTube re-renderiza la fila
        // de acciones 1–3 s despues de la navegacion y se lleva la pildora.
        [600, 1500, 3000, 5000].forEach(ms => {
            setTimeout(() => {
                if (watchVideoId === videoId && !dislikeKilled) insertDislikeTrigger(videoId);
            }, ms);
        });
    }

    async function updateDislike(force = false) {
        if (dislikeKilled) return;
        if (!isVideoPage()) return;
        const mode = state.settings.dislikeMode;
        if (mode === "off") return;
        const videoId = getVideoId();
        if (!videoId) return;
        watchVideoId = videoId;
        removeDislikeTrigger();

        // En Shorts sin boton de dislike no hay ancla ni para el conteo ni para
        // la pildora on-demand: salimos antes de gastar la peticion.
        if (isShortsPage() && !(await waitForDislikeBtn(videoId))) {
            if (watchVideoId === videoId) log("shorts: sin boton de dislike, no se consulta RYD");
            return;
        }
        if (watchVideoId !== videoId) return;

        if (mode === "ondemand" && !force) {
            // Si ya lo consultamos hace poco, pintarlo no cuesta otra peticion.
            const hit = dislikeCache.get(videoId);
            const cached = hit && Date.now() - hit.ts < CACHE_TTL ? fmtCount(hit.count) : null;
            if (cached) paintDislikePersistently(videoId, cached);
            else paintDislikeTrigger(videoId);
            return;
        }

        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;

        paintDislikePersistently(videoId, text);
    }

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

    const session = {
        playedByVideo: new Map(),
        lastTickTs: 0,
        actedVideos: new Set(),
        actionTimestamps: [],
        scheduledFor: null,
        // Veces que no encontramos el boton para un video. Distingue "todavia no
        // renderiza" (reintentar) de "no existe" —el dislike de Shorts, retirado
        // por YouTube en junio de 2026—, que antes re-armaba la espera completa
        // en cada revisita al mismo Short sin llegar nunca a nada.
        missByVideo: new Map(),
    };

    const MAX_BTN_MISSES = 3;

    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 t.diagOff;
        if (!isVideoPage()) return t.diagNotVideo;
        const videoId = getVideoId();
        if (!videoId) return t.diagNoVideoId;
        const ch = detectCurrentChannel();
        if (!ch) return t.diagNoChannel;
        const kind = inList(ch.ucId, ch.handle);
        if (!kind) return t.diagNotListed(ch.name || ch.handle || ch.ucId);
        if (!isLoggedIn()) return t.diagNoSession;
        if ((session.missByVideo.get(videoId) || 0) >= MAX_BTN_MISSES) return t.diagNoButton;
        if (session.actedVideos.has(videoId)) return t.diagAlreadyActed;
        const played = session.playedByVideo.get(videoId) || 0;
        const min = state.settings.autoActMinWatchSec;
        const dMax = state.settings.autoActDelayMaxSec;
        if (played < min) return t.diagWaiting(played.toFixed(0), min);
        if (session.scheduledFor === videoId) return t.diagScheduled(dMax);
        return t.diagReady(kind === "fav");
    }

    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(t.rateLimited, "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) {
            const misses = (session.missByVideo.get(videoId) || 0) + 1;
            session.missByVideo.set(videoId, misses);
            if (misses >= MAX_BTN_MISSES) {
                log(`auto-act skip: boton no encontrado ${misses} veces, se deja de intentar en este video`);
                session.actedVideos.add(videoId);
            } else {
                log(`auto-act skip: boton no encontrado (${misses}/${MAX_BTN_MISSES})`);
            }
            return;
        }
        session.missByVideo.delete(videoId);
        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(t.dryRun(kind === "fav", ch.name || ch.handle || ch.ucId));
            session.actedVideos.add(videoId);
            return;
        }

        target.click();
        session.actionTimestamps.push(Date.now());
        session.actedVideos.add(videoId);
        toast(t.acted(kind === "fav", 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 src = s.textContent;
            if (!src || src.length < 100) continue;
            if (!src.includes("ytInitialPlayerResponse") && !src.includes("ytInitialData")) continue;
            const m = src.match(/"channelId":"(UC[\w-]+)"/) || src.match(/"externalChannelId":"(UC[\w-]+)"/) || src.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",
        // Se queda por si sobrevive en alguna superficie, pero en la portada esta
        // a 0 desde la migracion a view-models: las miniaturas de Shorts son
        // ytm-shorts-lockup-view-model-v2. NO añadirlas aqui: no traen el canal
        // ni en el DOM ni en rawProps/componentProps/data (comprobado el
        // 2026-08-20), solo el /shorts/<id>, asi que decorateCard no tendria con
        // que resolverlo y sacarlo exigiria una peticion por miniatura.
        "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" ? t.bannerFav : t.bannerAvoid;
                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(t.removedFrom(kind === "fav"));
                } else {
                    addChannel(kind, ch);
                    toast(t.addedTo(kind === "fav", ch.name || ch.handle || ch.ucId));
                }
                onChange?.();
            });
            return b;
        };
        wrap.appendChild(mkBtn(t.pillFav(current === "fav"), "fav", current === "fav"));
        wrap.appendChild(mkBtn(t.pillAvoid(current === "avoid"), "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="${t.close}">×</button>
                </div>
                <div class="yct-tabs">
                    <button data-tab="fav" class="yct-tab yct-tab-active">${t.tabFav} (<span data-count="fav">0</span>)</button>
                    <button data-tab="avoid" class="yct-tab">${t.tabAvoid} (<span data-count="avoid">0</span>)</button>
                    <button data-tab="settings" class="yct-tab">${t.tabSettings}</button>
                    <button data-tab="about" class="yct-tab">${t.tabAbout}</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-tab-body yct-hidden" data-body="about"></div>
                <div class="yct-modal-foot">
                    <button type="button" class="yct-btn" data-act="export">${t.doExport}</button>
                    <button type="button" class="yct-btn" data-act="import">${t.doImport}</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(tab => {
            tab.addEventListener("click", () => {
                overlay.querySelectorAll(".yct-tab").forEach(x => x.classList.remove("yct-tab-active"));
                tab.classList.add("yct-tab-active");
                const which = tab.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(t.imported);
                    } catch (e) { toast(t.invalidFile, "error"); }
                };
                r.readAsText(f);
            });
            input.click();
        });
        renderManagerLists(overlay);
        renderManagerSettings(overlay);
        renderManagerAbout(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">${t.emptyList}</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">${t.remove}</button>
                `);
                row.querySelector("button").addEventListener("click", () => {
                    removeChannel(kind, key);
                    renderManagerLists(overlay);
                    refreshAllUi();
                    toast(t.removed(e.name || key));
                });
                body.appendChild(row);
            }
        }
    }

    function renderManagerSettings(overlay) {
        const body = overlay.querySelector('[data-body="settings"]');
        setHTML(body, `
            <h3 class="yct-section">${t.secVisual}</h3>
            <label class="yct-opt"><input type="checkbox" data-set="showFab"> ${t.optFab}</label>
            <label class="yct-opt"><input type="checkbox" data-set="hideAvoid"> ${t.optHideAvoid}</label>
            <label class="yct-opt"><input type="checkbox" data-set="showWatchBanner"> ${t.optBanner}</label>

            <h3 class="yct-section">${t.secPrivacy}</h3>
            <label class="yct-opt yct-opt-num">
                ${t.dislikeCount}
                <select data-set-sel="dislikeMode">
                    <option value="auto">${t.modeAuto}</option>
                    <option value="ondemand">${t.modeOnDemand}</option>
                    <option value="off">${t.modeOff}</option>
                </select>
            </label>
            <p class="yct-hint">${t.privacyHint}</p>

            <h3 class="yct-section">${t.secAuto}</h3>
            <div class="yct-warn">
                <strong>${t.warnTitle}</strong>
                ${t.warnBody}
            </div>
            <label class="yct-opt"><input type="checkbox" data-set="autoActEnabled"> ${t.optAutoEnabled}</label>
            <label class="yct-opt"><input type="checkbox" data-set="autoActDryRun"> ${t.optDryRun}</label>
            <label class="yct-opt yct-opt-num">
                ${t.numRateLimit}
                <input type="number" min="1" max="60" data-set-num="autoActRateLimitPerHour">
            </label>
            <label class="yct-opt yct-opt-num">
                ${t.numMinWatch}
                <input type="number" min="1" max="600" data-set-num="autoActMinWatchSec">
            </label>
            <label class="yct-opt yct-opt-num">
                ${t.numDelayMin}
                <input type="number" min="0" max="120" data-set-num="autoActDelayMinSec">
            </label>
            <label class="yct-opt yct-opt-num">
                ${t.numDelayMax}
                <input type="number" min="0" max="600" data-set-num="autoActDelayMaxSec">
            </label>

            <p class="yct-hint">${t.guardrailsHint}</p>

            <h3 class="yct-section">${t.secStatus}</h3>
            <div class="yct-diag" data-diag>—</div>
            <button type="button" class="yct-btn" data-act="diag-refresh">${t.refreshDiag}</button>

            <p class="yct-hint">${t.settingsFoot(SCRIPT_VERSION)}</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("select[data-set-sel]").forEach(sel => {
            const key = sel.dataset.setSel;
            sel.value = state.settings[key];
            sel.addEventListener("change", () => {
                state.settings[key] = sel.value;
                saveState();
                if (key === "dislikeMode") {
                    // Limpiar el estado pintado y volver a evaluar con el modo nuevo.
                    removeDislikeTrigger();
                    dislikeKilled = false;
                    dislikeErrors = 0;
                    updateDislike();
                    // Un conteo ya pintado sobrescribio el texto del boton de
                    // YouTube; no guardamos el original, asi que solo se limpia
                    // al recargar.
                    if (sel.value === "off") toast(t.dislikeOffToast, "info", 5000);
                }
            });
        });
        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();
            });
        });
    }

    // La ficha va en rejilla de dos columnas, como el modal de informacion de los
    // scripts de Twitch y Kick. Antes la version, el repo y el autor iban apretados
    // en una sola linea gris de pie y Ko-fi en una frase suelta: eran los mismos
    // cinco datos, pero sin poder recorrerlos con la vista.
    function renderManagerAbout(overlay) {
        const body = overlay.querySelector('[data-body="about"]');
        if (!body) return;
        const meta = [
            [t.aboutName, "YouTube Channel Tools"],
            [t.aboutVersion, SCRIPT_VERSION],
            [t.aboutAuthor, "g31w0fw0rld"],
            ["GitHub:", REPO],
            ["☕ Ko-fi:", KOFI],
        ].map(([k, v]) => `<div class="yct-meta-k">${k}</div><div class="yct-meta-v">${v}</div>`).join("");
        setHTML(body, `
            <h3 class="yct-section">${t.aboutTitle}</h3>
            <div class="yct-meta">${meta}</div>
            <div class="yct-about">
                <p>${t.aboutIntro}</p>
                <ul>${t.aboutItems.map(li => `<li>${li}</li>`).join("")}</ul>
                <p>${t.aboutClosing}</p>
            </div>
        `);
    }

    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-opt-num select { background: #2a2a2a; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 4px 6px; font: inherit; max-width: 60%; }
            /* Vive dentro de la fila de acciones de YouTube (flex), asi que se
               declara como flex item para que no lo aplaste ni lo colapse. */
            .yct-dislike-trigger { display: inline-flex; align-items: center; flex: 0 0 auto; white-space: nowrap; height: 36px; margin-left: 8px; background: var(--yt-spec-badge-chip-background, #303030); color: var(--yt-spec-text-primary, #fff); border: none; border-radius: 18px; padding: 0 14px; font: 500 14px sans-serif; cursor: pointer; }
            .yct-dislike-trigger:hover { background: var(--yt-spec-button-chip-background-hover, #3f3f3f); }
            .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; }
            /* Ficha de "Saber más": rejilla de dos columnas para que los cinco
               valores queden alineados en vez de escalonados según lo que mida cada
               etiqueta. minmax(0,1fr) + overflow-wrap para que la URL parta en vez
               de estirar el panel. */
            .yct-meta { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 10px; row-gap: 5px; font-size: 13px; margin: 8px 0 14px; padding-bottom: 14px; border-bottom: 1px solid #333; }
            .yct-meta-k { font-weight: 600; color: #888; white-space: nowrap; }
            .yct-meta-v { color: #ddd; min-width: 0; overflow-wrap: anywhere; }
            .yct-meta-v a { color: #8ab4f8; text-decoration: underline; }
            .yct-about { color: #bbb; font-size: 13px; line-height: 1.5; }
            .yct-about ul { margin: 6px 0; padding-left: 18px; }
            .yct-about li { margin-bottom: 5px; }
            .yct-about strong { color: #eee; }
            .yct-about a { color: #8ab4f8; }
        `);
    }

    // ---------- 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", t.openManager);
        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() {
        t = I18N[detectLang()];
        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(t.openManager, 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();
    }
})();