Crunchyroll — Player Plus

Adiciona recursos avançados ao player da Crunchyroll para maratonar: pular abertura/recapitulação automático, modo teatro sem distrações (widescreen), começar em X segundos (por anime), próximo episódio automático (por anime) e modo Picture-in-Picture (PiP) habilitado.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Crunchyroll — Player Plus
// @namespace    https://github.com/leorcdias/
// @version      1.5.1
// @description  Adiciona recursos avançados ao player da Crunchyroll para maratonar: pular abertura/recapitulação automático, modo teatro sem distrações (widescreen), começar em X segundos (por anime), próximo episódio automático (por anime) e modo Picture-in-Picture (PiP) habilitado.
// @author       Leonardo Dias
// @homepageURL  https://github.com/leorcdias/crunchyroll-player-plus
// @supportURL   https://github.com/leorcdias/crunchyroll-player-plus/issues
// @icon         https://www.google.com/s2/favicons?sz=64&domain=crunchyroll.com
// @match        https://*.crunchyroll.com/*
// @match        https://static.crunchyroll.com/vilos-v2/web/vilos/player.html*
// @run-at       document-idle
// @require      https://code.jquery.com/jquery-3.7.1.min.js
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// ==/UserScript==

/* global $ */

(function () {
    "use strict";

    const isTop = window.top === window.self;
    const isPlayerFrame = /static\.crunchyroll\.com\/vilos-v2\/web\/vilos\/player\.html/i.test(location.href);

    // =========================
    // TOP (www.crunchyroll.com)
    // =========================
    const STORAGE_KEY_THEATER_MODE_TOP = "ld_cr_mode_theater_enabled";
    const STORAGE_KEY_NEXT_MAP = "ld_cr_next_by_seconds_map"; // { animeKey: seconds }
    const STORAGE_KEY_START_MAP = "ld_cr_start_at_seconds_map"; // { animeKey: seconds }

    const DEFAULT_THEATER_MODE = true;

    if (isTop && !isPlayerFrame) initTopPage();
    else initPlayerIframe();

    function initTopPage() {
        const state = {
            enabled: loadBool(STORAGE_KEY_THEATER_MODE_TOP, DEFAULT_THEATER_MODE),
            lastUrl: location.href,
        };

        GM_addStyle(`
      html.ld-cr-theater-mode,
      body.ld-cr-theater-mode {
        overflow: hidden !important;
        background: #000 !important;
      }

      .ld-cr-theater-mode .app-layout__header--ywueY,
      .ld-cr-theater-mode .erc-large-header,
      .ld-cr-theater-mode header[data-t="header-default"],
      .ld-cr-theater-mode .app-layout__footer--jgOfu,
      .ld-cr-theater-mode [data-t="footer"],
      .ld-cr-theater-mode .app-layout__aside--IG1cw,
      .ld-cr-theater-mode .banner-wrapper,
      .ld-cr-theater-mode .content-wrapper--MF5LS,
      .ld-cr-theater-mode .videos-wrapper,
      .ld-cr-theater-mode .erc-watch-episode .content-wrapper--MF5LS,
      .ld-cr-theater-mode .erc-watch-episode .videos-wrapper {
        display: none !important;
      }

      .ld-cr-theater-mode .erc-watch-episode,
      .ld-cr-theater-mode .erc-watch-episode-layout {
        margin: 0 !important;
        padding: 0 !important;
        max-width: none !important;
        width: 100% !important;
        background: #000 !important;
      }

      .ld-cr-theater-mode .video-player-wrapper {
        position: fixed !important;
        inset: 0 !important;
        width: 100vw !important;
        height: 100vh !important;
        z-index: 2147483000 !important;
        background: #000 !important;
      }

      .ld-cr-theater-mode .video-player-spacer { display:none !important; }

      .ld-cr-theater-mode iframe.video-player {
        position: absolute !important;
        inset: 0 !important;
        width: 100% !important;
        height: 100% !important;
        border: 0 !important;
        background: #000 !important;
      }
    `);

        GM_registerMenuCommand("Alternar Modo Teatro (Crunchyroll)", () => toggle());

        window.addEventListener("message", (ev) => {
            if (!ev?.data) return;

            if (ev.data.type === "LD_CR_TOGGLE_THEATER_MODE") toggle();
            if (ev.data.type === "LD_CR_GO_NEXT_EPISODE") goNextEpisode();

            if (ev.data.type === "LD_CR_GET_CFG") {
                sendCfgToIframe(ev.source);
            }

            if (ev.data.type === "LD_CR_SET_NEXT_SECONDS") {
                const animeKey = getAnimeKey();
                if (!animeKey) return;
                const map = loadJson(STORAGE_KEY_NEXT_MAP, {});
                map[animeKey] = clampInt(ev.data.seconds, 0, 600);
                saveJson(STORAGE_KEY_NEXT_MAP, map);
                broadcastCfgToIframe();
            }

            if (ev.data.type === "LD_CR_SET_START_SECONDS") {
                const animeKey = getAnimeKey();
                if (!animeKey) return;
                const map = loadJson(STORAGE_KEY_START_MAP, {});
                map[animeKey] = clampInt(ev.data.seconds, 0, 600);
                saveJson(STORAGE_KEY_START_MAP, map);
                broadcastCfgToIframe();
            }
        });

        // Atalho T (Modo Teatro)
        document.addEventListener(
            "keydown",
            (e) => {
                if (!isWatchRoute()) return;
                if (!isPlainKey(e, "t")) return;
                e.preventDefault();
                toggle();
            },
            true,
        );

        routeApply();
        setInterval(() => {
            if (location.href !== state.lastUrl) {
                state.lastUrl = location.href;
                setTimeout(routeApply, 250);
            }
        }, 400);

        function toggle() {
            if (!isWatchRoute()) return;
            state.enabled = !state.enabled;
            saveBool(STORAGE_KEY_THEATER_MODE_TOP, state.enabled);
            apply(state.enabled);
            notifyIframeTheaterMode(state.enabled);
        }

        function apply(enable) {
            $("html, body").toggleClass("ld-cr-theater-mode", enable);
            if (enable) nudgePlayerSizing();
        }

        function routeApply() {
            if (!isWatchRoute()) {
                $("html, body").removeClass("ld-cr-theater-mode");
                return;
            }
            apply(state.enabled);
            notifyIframeTheaterMode(state.enabled);
            broadcastCfgToIframe();
        }

        function notifyIframeTheaterMode(enable) {
            const iframe = document.querySelector("iframe.video-player");
            iframe?.contentWindow?.postMessage({ type: "LD_CR_THEATER_MODE_STATE", enabled: !!enable }, "*");
        }

        function broadcastCfgToIframe() {
            const iframe = document.querySelector("iframe.video-player");
            if (!iframe?.contentWindow) return;
            sendCfgToIframe(iframe.contentWindow);
        }

        function sendCfgToIframe(targetWin) {
            if (!targetWin) return;

            const animeKey = getAnimeKey();
            const nextMap = loadJson(STORAGE_KEY_NEXT_MAP, {});
            const startMap = loadJson(STORAGE_KEY_START_MAP, {});
            const nextSeconds = animeKey ? Number(nextMap[animeKey]) || 0 : 0;
            const startSeconds = animeKey ? Number(startMap[animeKey]) || 0 : 0;

            targetWin.postMessage(
                {
                    type: "LD_CR_CFG",
                    animeKey,
                    nextSeconds,
                    startSeconds,
                },
                "*",
            );
        }

        function nudgePlayerSizing() {
            const tryFix = () => {
                const iframe = document.querySelector("iframe.video-player");
                const wrapper = document.querySelector(".video-player-wrapper");
                if (!iframe || !wrapper) return false;
                iframe.setAttribute("allowfullscreen", "");
                iframe.style.border = "0";
                return true;
            };
            if (tryFix()) return;

            let tries = 0;
            const t = setInterval(() => {
                tries++;
                if (tryFix() || tries > 30) clearInterval(t);
            }, 250);
        }

        function goNextEpisode() {
            const next = document.querySelector('[data-t="next-episode"] a[href*="/watch/"]') || document.querySelector('.erc-prev-next-episode[data-t="next-episode"] a[href*="/watch/"]') || [...document.querySelectorAll('a[href*="/watch/"]')].find((a) => a.href && !a.href.includes(location.pathname));

            next?.click();
        }

        function getAnimeKey() {
            const a = document.querySelector('.current-media-parent-ref a.show-title-link[href*="/series/"]');
            const href = a?.getAttribute("href") || "";
            const m = href.match(/\/series\/([^/]+)\/([^/?#]+)/i);
            if (m) return `${m[1]}/${m[2]}`;
            return null;
        }
    }

    // ==================================
    // IFRAME (player.html)
    // ==================================
    function initPlayerIframe() {
        const CR_ORANGE = "#F47521";

        const STORAGE_KEY_SKIP_INTRO = "ld_vilos_skip_intro_enabled";
        const STORAGE_KEY_SKIP_RECAP = "ld_vilos_skip_recap_enabled";

        const state = {
            theaterModeEnabled: false,

            skipIntroEnabled: loadBool(STORAGE_KEY_SKIP_INTRO, false),
            skipRecapEnabled: loadBool(STORAGE_KEY_SKIP_RECAP, false),

            animeKey: null,
            nextSeconds: 0,
            startSeconds: 0,

            firedNextForEpisode: false,
            appliedStartForEpisode: false,
            lastVideoSrcKey: null,
            lastAnimeKey: null,

            introIgnoreUntilGone: false,
            recapIgnoreUntilGone: false,
            nextCancelUntil: 0,

            nextArmed: false,
        };

        // Fullscreen-safe UI root
        function uiRoot() {
            return document.fullscreenElement || document.webkitFullscreenElement || document.documentElement;
        }

        GM_addStyle(`
      #ld-netflix-countdown {
        position: fixed;
        right: 18px;
        bottom: 92px;
        z-index: 2147483647;
        display: none;
        align-items: center;
        gap: 10px;
        padding: 10px 12px;
        border-radius: 14px;
        background: rgba(0,0,0,.60);
        border: 1px solid rgba(255,255,255,.16);
        color: rgba(255,255,255,.92);
        backdrop-filter: blur(8px);
        pointer-events: none;
      }
      #ld-netflix-countdown .ld-nc-text {
        display: flex;
        flex-direction: column;
        gap: 2px;
        min-width: 170px;
      }
      #ld-netflix-countdown .ld-nc-title {
        font: 700 13px/1.1 Arial, sans-serif;
        color: rgba(255,255,255,.95);
      }
      #ld-netflix-countdown .ld-nc-sub {
        font: 12px/1.1 Arial, sans-serif;
        color: rgba(255,255,255,.72);
      }
      #ld-netflix-countdown .ld-nc-circle { width:34px; height:34px; flex:0 0 34px; }
      #ld-netflix-countdown .ld-nc-sec { font: 900 12px/1 Arial, sans-serif; fill: rgba(255,255,255,.95); }
    `);

        const nc = document.createElement("div");
        nc.id = "ld-netflix-countdown";
        nc.innerHTML = `
      <div class="ld-nc-circle">
        <svg width="34" height="34" viewBox="0 0 36 36" aria-hidden="true">
          <path d="M18 2.5 a 15.5 15.5 0 0 1 0 31 a 15.5 15.5 0 0 1 0 -31"
            fill="none" stroke="rgba(255,255,255,.18)" stroke-width="3"/>
          <path id="ld-nc-progress"
            d="M18 2.5 a 15.5 15.5 0 0 1 0 31 a 15.5 15.5 0 0 1 0 -31"
            fill="none" stroke="${CR_ORANGE}" stroke-width="3" stroke-linecap="round"
            stroke-dasharray="0, 100"/>
          <text id="ld-nc-sec" x="18" y="21" text-anchor="middle" class="ld-nc-sec">5</text>
        </svg>
      </div>
      <div class="ld-nc-text">
        <div id="ld-nc-title" class="ld-nc-title">...</div>
        <div class="ld-nc-sub">Pressione ESC para cancelar</div>
      </div>
    `;
        uiRoot().appendChild(nc);

        const ncProg = nc.querySelector("#ld-nc-progress");
        const ncSec = nc.querySelector("#ld-nc-sec");
        const ncTitle = nc.querySelector("#ld-nc-title");

        function remountUI() {
            const root = uiRoot();
            if (nc.parentElement !== root) root.appendChild(nc);
        }
        document.addEventListener("fullscreenchange", remountUI);
        document.addEventListener("webkitfullscreenchange", remountUI);

        const countdown = {
            active: false,
            kind: null, // "intro" | "recap" | "next"
            endAt: 0,
            totalMs: 5000,
            raf: null,
            onDone: null,
        };

        function startCountdown(kind, title, onDone) {
            remountUI();

            const now = Date.now();
            if (countdown.active && countdown.kind === kind && now < countdown.endAt) return;

            stopCountdown(false);

            countdown.active = true;
            countdown.kind = kind;
            countdown.endAt = now + countdown.totalMs;
            countdown.onDone = onDone;

            ncTitle.textContent = title;
            nc.style.display = "flex";

            const tick = () => {
                if (!countdown.active) return;

                const v = document.querySelector("video");
                if (v && v.paused) {
                    stopCountdown(true);
                    return;
                }

                const left = Math.max(0, countdown.endAt - Date.now());

                ncSec.textContent = String(Math.ceil(left / 1000));
                const pct = 100 - Math.round((left / countdown.totalMs) * 100);
                ncProg.setAttribute("stroke-dasharray", `${pct}, 100`);

                if (left <= 0) {
                    const fn = countdown.onDone;
                    stopCountdown(false);
                    try {
                        fn?.();
                    } catch (_) {}
                    return;
                }
                countdown.raf = requestAnimationFrame(tick);
            };

            countdown.raf = requestAnimationFrame(tick);
        }

        function stopCountdown(isCancel) {
            if (countdown.raf) cancelAnimationFrame(countdown.raf);
            countdown.raf = null;

            const oldKind = countdown.kind;

            countdown.active = false;
            countdown.kind = null;
            countdown.endAt = 0;
            countdown.onDone = null;

            nc.style.display = "none";

            if (!isCancel) return;

            if (oldKind === "intro") state.introIgnoreUntilGone = true;
            if (oldKind === "recap") state.recapIgnoreUntilGone = true;
            if (oldKind === "next") state.nextCancelUntil = Date.now() + 6000;
        }

        document.addEventListener(
            "keydown",
            (e) => {
                if (e.key === "Escape" && countdown.active) {
                    e.preventDefault();
                    stopCountdown(true);
                }
            },
            true,
        );

        // Messages from TOP
        window.addEventListener("message", (ev) => {
            if (!ev?.data) return;

            if (ev.data.type === "LD_CR_THEATER_MODE_STATE") {
                state.theaterModeEnabled = !!ev.data.enabled;
                updateMenuVisuals(true);
            }

            if (ev.data.type === "LD_CR_CFG") {
                state.animeKey = ev.data.animeKey || state.animeKey;
                state.nextSeconds = clampInt(ev.data.nextSeconds, 0, 600);
                state.startSeconds = clampInt(ev.data.startSeconds, 0, 600);
                updateMenuVisuals(true);
            }
        });

        window.parent.postMessage({ type: "LD_CR_GET_CFG" }, "*");

        // Hotkeys in iframe
        document.addEventListener(
            "keydown",
            (e) => {
                if (!isPlainKey(e, "t")) return;
                e.preventDefault();
                window.parent.postMessage({ type: "LD_CR_TOGGLE_THEATER_MODE" }, "*");
            },
            true,
        );

        document.addEventListener(
            "keydown",
            (e) => {
                if (!isPlainKey(e, "n")) return;
                e.preventDefault();
                const v = document.querySelector("video");
                if (!v || !isFinite(v.duration) || !isFinite(v.currentTime) || v.duration <= 0) return;

                const remaining = Math.max(0, Math.round(v.duration - v.currentTime));
                window.parent.postMessage({ type: "LD_CR_SET_NEXT_SECONDS", seconds: remaining }, "*");
            },
            true,
        );

        // Poll
        setInterval(() => {
            remountUI();
            enablePiPOnVideo();
            resetForNewEpisodeIfNeeded();
            injectMenuOnce();
            updateMenuVisuals();
            applyStartAtInstant();
            handleSkipButtons();
            handleNext();
        }, 400);

        function enablePiPOnVideo() {
            const video = document.querySelector("video");
            if (!video) return;
            try {
                video.disablePictureInPicture = false;
            } catch (_) {}
            if (video.hasAttribute("controlsList")) {
                const cl = video.getAttribute("controlsList") || "";
                if (cl.includes("nopictureinpicture")) {
                    video.setAttribute("controlsList", cl.replace("nopictureinpicture", "").trim());
                }
            }
        }

        function getVideoSrcKey() {
            const v = document.querySelector("video");
            if (!v) return null;
            return v.currentSrc || v.src || v.querySelector("source")?.getAttribute("src") || null;
        }

        function resetForNewEpisodeIfNeeded() {
            const srcKey = getVideoSrcKey();
            const animeKey = state.animeKey || null;

            if (animeKey && animeKey !== state.lastAnimeKey) {
                state.lastAnimeKey = animeKey;
                state.firedNextForEpisode = false;
                state.appliedStartForEpisode = false;
                state.nextArmed = false;
                state.introIgnoreUntilGone = false;
                state.recapIgnoreUntilGone = false;
                if (countdown.kind) stopCountdown(false);
            }

            if (srcKey && srcKey !== state.lastVideoSrcKey) {
                state.lastVideoSrcKey = srcKey;
                state.firedNextForEpisode = false;
                state.appliedStartForEpisode = false;
                state.nextArmed = false;
                state.introIgnoreUntilGone = false;
                state.recapIgnoreUntilGone = false;
                if (countdown.kind) stopCountdown(false);

                const sb = document.querySelector('[data-testid="skipButton"]');
                if (sb) delete sb.dataset.ldSkipped;
            }
        }

        // Começar em X segundos
        function applyStartAtInstant() {
            const v = document.querySelector("video");
            if (!v) return;

            if (state.startSeconds <= 0) return;
            if (state.appliedStartForEpisode) return;

            // Só considerar "início" se está MUITO perto do zero
            // (se o player restaurou progresso, geralmente já vem > 1s)
            const isNearZeroNow = v.currentTime <= 0.6;

            // Ajuda extra: se já houve playback (retomada), não mexe
            const hasPlayed = v.played && v.played.length > 0;

            // Se já não está no zero ou já tem histórico de played, não aplicar
            if (!isNearZeroNow || hasPlayed) {
                state.appliedStartForEpisode = true; // trava pra não insistir
                return;
            }

            // A Crunchyroll às vezes restaura o tempo APÓS dar play.
            // Então a gente espera o primeiro "playing/timeupdate" e revalida.
            const tryApply = () => {
                if (state.appliedStartForEpisode) return;

                const v2 = document.querySelector("video");
                if (!v2) return;

                // Se o player restaurou o tempo, currentTime vai estar > 0.6 — então não aplica
                if (v2.currentTime > 0.6) {
                    state.appliedStartForEpisode = true;
                    cleanup();
                    return;
                }

                state.appliedStartForEpisode = true;
                cleanup();

                try {
                    v2.currentTime = state.startSeconds;
                } catch (_) {}
            };

            const onPlaying = () => tryApply();
            const onTimeUpdate = () => tryApply();

            const cleanup = () => {
                v.removeEventListener("playing", onPlaying);
                v.removeEventListener("timeupdate", onTimeUpdate);
            };

            // registra e tenta também com um timeout curto
            v.addEventListener("playing", onPlaying, { once: false });
            v.addEventListener("timeupdate", onTimeUpdate, { once: false });
            setTimeout(tryApply, 350);
        }

        function handleSkipButtons() {
            const v = document.querySelector("video");
            if (v?.paused) {
                if (countdown.kind === "intro" || countdown.kind === "recap") stopCountdown(true);
                return;
            }

            const skipContainer = document.querySelector('[data-testid="skipButton"]');

            if (state.introIgnoreUntilGone && !skipContainer) state.introIgnoreUntilGone = false;
            if (state.recapIgnoreUntilGone && !skipContainer) state.recapIgnoreUntilGone = false;

            if (!skipContainer) {
                if (countdown.kind === "intro" || countdown.kind === "recap") stopCountdown(false);
                return;
            }

            const btn = skipContainer.querySelector('[role="button"][tabindex="0"]') || skipContainer.querySelector('[role="button"]');
            const aria = btn?.getAttribute("aria-label") || "";
            const txt = (skipContainer.querySelector('[data-testid="skipIntroText"]')?.textContent || "").trim();

            const isIntro = /abertura/i.test(aria) || /abertura/i.test(txt);
            const isRecap = /recapitula/i.test(aria) || /recapitula/i.test(txt);

            if (skipContainer.dataset.ldSkipped === "1") return;

            if (isIntro) {
                if (!state.skipIntroEnabled || state.introIgnoreUntilGone) {
                    if (countdown.kind === "intro") stopCountdown(false);
                    return;
                }

                startCountdown("intro", "Pulando abertura", () => {
                    const v2 = document.querySelector("video");
                    if (v2?.paused) return;

                    const sc = document.querySelector('[data-testid="skipButton"]');
                    if (!sc || sc.dataset.ldSkipped === "1") return;

                    const b = sc.querySelector('[role="button"][tabindex="0"]') || sc.querySelector('[role="button"]');
                    const a = b?.getAttribute("aria-label") || "";
                    const t = (sc.querySelector('[data-testid="skipIntroText"]')?.textContent || "").trim();

                    if (/abertura/i.test(a) || /abertura/i.test(t)) {
                        sc.dataset.ldSkipped = "1";
                        b?.click();
                    }
                });
                return;
            }

            if (isRecap) {
                if (!state.skipRecapEnabled || state.recapIgnoreUntilGone) {
                    if (countdown.kind === "recap") stopCountdown(false);
                    return;
                }

                startCountdown("recap", "Pulando recapitulação", () => {
                    const v2 = document.querySelector("video");
                    if (v2?.paused) return;

                    const sc = document.querySelector('[data-testid="skipButton"]');
                    if (!sc || sc.dataset.ldSkipped === "1") return;

                    const b = sc.querySelector('[role="button"][tabindex="0"]') || sc.querySelector('[role="button"]');
                    const a = b?.getAttribute("aria-label") || "";
                    const t = (sc.querySelector('[data-testid="skipIntroText"]')?.textContent || "").trim();

                    if (/recapitula/i.test(a) || /recapitula/i.test(t)) {
                        sc.dataset.ldSkipped = "1";
                        b?.click();
                    }
                });
                return;
            }

            if (countdown.kind === "intro" || countdown.kind === "recap") stopCountdown(false);
        }

        function handleNext() {
            const v = document.querySelector("video");
            if (!v || !isFinite(v.duration) || !isFinite(v.currentTime) || v.duration <= 0) return;

            if (v.paused) {
                if (countdown.kind === "next") stopCountdown(true);
                return;
            }

            if (state.nextSeconds <= 0 || state.firedNextForEpisode) {
                if (countdown.kind === "next") stopCountdown(false);
                state.nextArmed = false;
                return;
            }

            if (Date.now() < state.nextCancelUntil) {
                if (countdown.kind === "next") stopCountdown(false);
                return;
            }

            const remaining = v.duration - v.currentTime;

            if (remaining > state.nextSeconds) {
                state.nextArmed = false;
                if (countdown.kind === "next") stopCountdown(false);
                return;
            }

            if (state.nextArmed) return;
            state.nextArmed = true;

            startCountdown("next", "Próximo episódio", () => {
                const v2 = document.querySelector("video");
                if (!v2 || v2.paused) {
                    state.nextArmed = false;
                    return;
                }

                const rem2 = v2.duration - v2.currentTime;
                if (rem2 <= state.nextSeconds && !state.firedNextForEpisode) {
                    state.firedNextForEpisode = true;
                    window.parent.postMessage({ type: "LD_CR_GO_NEXT_EPISODE" }, "*");
                } else {
                    state.nextArmed = false;
                }
            });
        }

        // Settings Menu
        function injectMenuOnce() {
            const menu = document.getElementById("velocity-settings-menu");
            if (!menu) return;
            if (menu.dataset.ldInjected === "1") return;

            const autoplayItem = menu.querySelector('[data-testid="vilos-settings_autoplay_toggle"]');
            if (!autoplayItem) return;

            const theaterModeItem = ensureToggleLikeAutoplay(menu, autoplayItem, {
                testId: "ld-vilos-theater-mode",
                label: "Modo Teatro (T)",
                getState: () => state.theaterModeEnabled,
                onToggle: () => window.parent.postMessage({ type: "LD_CR_TOGGLE_THEATER_MODE" }, "*"),
            });

            const skipIntroItem = ensureToggleLikeAutoplay(menu, autoplayItem, {
                testId: "ld-vilos-skip-intro",
                label: "Pular abertura",
                insertAfter: theaterModeItem,
                getState: () => state.skipIntroEnabled,
                onToggle: () => {
                    state.skipIntroEnabled = !state.skipIntroEnabled;
                    saveBool(STORAGE_KEY_SKIP_INTRO, state.skipIntroEnabled);
                },
            });

            const skipRecapItem = ensureToggleLikeAutoplay(menu, autoplayItem, {
                testId: "ld-vilos-skip-recap",
                label: "Pular recapitulação",
                insertAfter: skipIntroItem,
                getState: () => state.skipRecapEnabled,
                onToggle: () => {
                    state.skipRecapEnabled = !state.skipRecapEnabled;
                    saveBool(STORAGE_KEY_SKIP_RECAP, state.skipRecapEnabled);
                },
            });

            const startRow = ensureSecondsRow(menu, autoplayItem, {
                testId: "ld-vilos-start-at",
                label: "Começar em",
                insertAfter: skipRecapItem,
                getValue: () => state.startSeconds,
                onClick: () => {
                    const cur = state.startSeconds || 0;
                    const val = prompt(`Começar em (segundos)\n0 = não pular\nAnime: ${state.animeKey || "(carregando...)"}`, String(cur));
                    if (val === null) return;
                    const n = clampInt(val, 0, 600);
                    window.parent.postMessage({ type: "LD_CR_SET_START_SECONDS", seconds: n }, "*");
                },
            });

            ensureSecondsRow(menu, autoplayItem, {
                testId: "ld-vilos-next-episode",
                label: "Próximo episódio",
                insertAfter: startRow,
                getValue: () => state.nextSeconds,
                onClick: () => {
                    const cur = state.nextSeconds || 0;
                    const val = prompt(`Próximo episódio (segundos)\n0 = não pular\nAnime: ${state.animeKey || "(carregando...)"}`, String(cur));
                    if (val === null) return;
                    const n = clampInt(val, 0, 600);
                    window.parent.postMessage({ type: "LD_CR_SET_NEXT_SECONDS", seconds: n }, "*");
                },
            });

            menu.dataset.ldInjected = "1";
            updateMenuVisuals(true);
        }

        function updateMenuVisuals(force = false) {
            const menu = document.getElementById("velocity-settings-menu");
            if (!menu || menu.dataset.ldInjected !== "1") return;

            updateToggleVisual(menu, "ld-vilos-theater-mode", state.theaterModeEnabled);
            updateToggleVisual(menu, "ld-vilos-skip-intro", state.skipIntroEnabled);
            updateToggleVisual(menu, "ld-vilos-skip-recap", state.skipRecapEnabled);

            updateSecondsVisual(menu, "ld-vilos-start-at", state.startSeconds, force);
            updateSecondsVisual(menu, "ld-vilos-next-episode", state.nextSeconds, force);
        }

        function ensureToggleLikeAutoplay(menu, autoplayItem, opts) {
            const { testId, label, insertAfter, getState, onToggle } = opts;
            const existing = menu.querySelector(`[data-testid="${testId}"]`);
            if (existing) return existing;

            const clone = autoplayItem.cloneNode(true);
            clone.setAttribute("data-testid", testId);
            clone.setAttribute("tabindex", "-1");

            const labelEl = findTextParent(clone, "Autoplay") || clone.querySelector('[dir="auto"]');
            if (labelEl) labelEl.textContent = label;

            const innerBtn = clone.querySelector("[aria-checked][data-test-state]") || clone.querySelector("[aria-checked]");
            const track = clone.querySelector('[style*="border-color"]');
            const knob = clone.querySelector('[style*="transform: translateX"]') || (track ? track.querySelector("div") : null);

            innerBtn?.setAttribute("data-ld-role", `${testId}-btn`);
            track?.setAttribute("data-ld-role", `${testId}-track`);
            knob?.setAttribute("data-ld-role", `${testId}-knob`);

            clone.addEventListener(
                "click",
                () => {
                    onToggle?.();
                    updateMenuVisuals();
                },
                false,
            );

            (insertAfter || autoplayItem).insertAdjacentElement("afterend", clone);
            return clone;
        }

        function ensureSecondsRow(menu, templateItem, opts) {
            const { testId, label, insertAfter, getValue, onClick } = opts;
            const existing = menu.querySelector(`[data-testid="${testId}"]`);
            if (existing) return existing;

            const row = templateItem.cloneNode(true);
            row.setAttribute("data-testid", testId);
            row.setAttribute("tabindex", "-1");

            const labelEl = findTextParent(row, "Autoplay") || row.querySelector('[dir="auto"]');
            if (labelEl) labelEl.textContent = label;

            row.querySelector('[style*="border-color"]')?.remove();

            const inner = row.querySelector("[aria-checked][data-test-state]") || row.querySelector("[aria-checked]") || row.firstElementChild || row;

            const labelNode = inner.querySelector('[dir="auto"]') || inner.querySelector("div");

            const value = document.createElement("div");
            value.setAttribute("data-ld-role", `${testId}-value`);
            if (labelNode?.classList?.length) value.className = Array.from(labelNode.classList).join(" ");

            value.style.minWidth = "64px";
            value.style.lineHeight = "18px";
            value.style.color = "rgb(218, 218, 218)";
            value.style.padding = "6px 10px";
            value.style.borderRadius = "10px";
            value.style.border = "1px solid rgba(255,255,255,.18)";
            value.style.background = "rgba(255,255,255,.06)";
            value.style.fontWeight = "700";
            value.style.textAlign = "right";
            value.style.marginLeft = "auto";
            value.style.whiteSpace = "nowrap";

            value.textContent = `${clampInt(getValue?.(), 0, 600)}s`;
            inner.appendChild(value);

            row.addEventListener("click", () => onClick?.(), false);
            insertAfter.insertAdjacentElement("afterend", row);
            return row;
        }

        function updateSecondsVisual(menu, testId, seconds, force) {
            const item = menu.querySelector(`[data-testid="${testId}"]`);
            if (!item) return;
            const value = item.querySelector(`[data-ld-role="${testId}-value"]`);
            if (!value) return;

            const nextText = `${clampInt(seconds, 0, 600)}s`;
            if (!force && value.textContent === nextText) return;
            value.textContent = nextText;
        }

        function updateToggleVisual(menu, testId, on) {
            const item = menu.querySelector(`[data-testid="${testId}"]`);
            if (!item) return;

            const innerBtn = item.querySelector(`[data-ld-role="${testId}-btn"]`);
            const track = item.querySelector(`[data-ld-role="${testId}-track"]`);
            const knob = item.querySelector(`[data-ld-role="${testId}-knob"]`);

            if (innerBtn) {
                innerBtn.setAttribute("aria-checked", on ? "true" : "false");
                innerBtn.setAttribute("data-test-state", on ? "true" : "false");
            }

            const autoplay = menu.querySelector('[data-testid="vilos-settings_autoplay_toggle"]');
            const btnTrue = autoplay?.querySelector('[data-test-state="true"]');
            const btnFalse = autoplay?.querySelector('[data-test-state="false"]');

            const trackTrue = btnTrue?.querySelector('[style*="border-color"]');
            const knobTrue = trackTrue?.querySelector('[style*="background-color"]') || trackTrue?.querySelector("div");

            const trackFalse = btnFalse?.querySelector('[style*="border-color"]');
            const knobFalse = trackFalse?.querySelector('[style*="background-color"]') || trackFalse?.querySelector("div");

            const onBorder = trackTrue?.style?.borderColor || "rgb(40, 189, 187)";
            const onBg = knobTrue?.style?.backgroundColor || "rgb(40, 189, 187)";
            const offBorder = trackFalse?.style?.borderColor || "rgb(160, 160, 160)";
            const offBg = knobFalse?.style?.backgroundColor || "rgb(160, 160, 160)";

            if (track) track.style.borderColor = on ? onBorder : offBorder;
            if (knob) {
                knob.style.backgroundColor = on ? onBg : offBg;
                knob.style.transform = on ? "translateX(24px)" : "translateX(4px)";
            }
        }

        function findTextParent(root, exactText) {
            const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
                acceptNode: (node) => ((node.nodeValue || "").trim() === exactText ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP),
            });
            return walker.nextNode() ? walker.currentNode.parentElement : null;
        }
    }

    // ========= shared helpers =========
    function isWatchRoute(url = location.href) {
        return /\/watch\//i.test(url);
    }

    function loadBool(key, fallback) {
        try {
            const raw = localStorage.getItem(key);
            if (raw === null) return fallback;
            return raw === "1";
        } catch {
            return fallback;
        }
    }

    function saveBool(key, v) {
        try {
            localStorage.setItem(key, v ? "1" : "0");
        } catch {}
    }

    function clampInt(v, min, max) {
        const n = Math.round(Number(v));
        if (!isFinite(n)) return min;
        return Math.max(min, Math.min(max, n));
    }

    function loadJson(key, fallbackObj) {
        try {
            const raw = localStorage.getItem(key);
            if (!raw) return fallbackObj;
            const obj = JSON.parse(raw);
            return obj && typeof obj === "object" ? obj : fallbackObj;
        } catch {
            return fallbackObj;
        }
    }

    function saveJson(key, obj) {
        try {
            localStorage.setItem(key, JSON.stringify(obj));
        } catch {}
    }

    function isPlainKey(e, key) {
        const tag = e.target && e.target.tagName ? e.target.tagName.toLowerCase() : "";
        if (tag === "input" || tag === "textarea" || (e.target && e.target.isContentEditable)) return false;
        return !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === key;
    }
})();