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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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