// AdBlock-Maxxer — YouTube "Reclaim" player (ISOLATED world).
//
// THE server-side-enforcement fix. On accounts/IPs that YouTube has bucketed into strict
// server-side ad enforcement, googlevideo.com refuses the media bytes (403) unless an ad
// plays — no in-page blocker (MV3 or MV2) can beat that, because the bytes never arrive.
//
// This sidesteps it entirely: a tiny LOCAL companion app (abm_companion.py on 127.0.0.1)
// extracts the same video ANONYMOUSLY through YouTube's app InnerTube client (android_vr) —
// which isn't ad-enforced — and range-proxies the bytes back. We hide youtube.com's own
// player and drop our own <video> in its place, fed from the companion. Everything else on
// the page (search, comments, recommendations) stays the native YouTube UI.
//
// If the companion isn't running, this is a silent no-op — the existing safeguard
// (youtube.js auto-pause) takes over so videos still play (with ads) instead of black.
//
// Gated by the "ytReclaim" toggle (default ON). Needs the youtube-reclaim DNR ruleset
// (strips YouTube's CSP so our localhost media element is allowed) — background enables it.

(function () {
  "use strict";

  const PORT = 47823;
  const BASE = "http://127.0.0.1:" + PORT;
  const PLAYER_ID = "abm-reclaim-video";

  let enabled = true;            // ytReclaim master toggle (default on)
  let forceAlways = false;       // ytReclaimAlways: take over every video (not just enforced ones)
  let mountedId = null;          // video id we currently have a player for
  let resolving = false;
  let prefetch = { id: null, p: null }; // in-flight resolve, fired ASAP to cut wait time
  let cur = null;                // current selection: { id, video, data, vFmt, aFmt, capLang }
  let reclaimBackoffId = null;   // do not remount a video whose fresh retry also failed

  chrome.storage.local.get(["ytReclaim", "ytReclaimAlways"], (r) => {
    if (r && r.ytReclaim === false) enabled = false;
    if (r && r.ytReclaimAlways === true) forceAlways = true;
    boot();
  });
  chrome.storage.onChanged.addListener((c) => {
    if (c.ytReclaim) {
      enabled = c.ytReclaim.newValue !== false;
      reclaimBackoffId = null;
      if (!enabled) teardown();
      else sync();
    }
    if (c.ytReclaimAlways) {
      forceAlways = c.ytReclaimAlways.newValue === true;
      reclaimBackoffId = null;
      if (!forceAlways && !isEnforced()) teardown();
      sync();
    }
  });

  // AUTO mode (default): only take over when YouTube is actually enforcing (a 403 on the media,
  // detected by youtube-main.js → sets this flag). Otherwise the native player runs, ad-stripped,
  // so seeking stays instant. ytReclaimAlways forces takeover on every video.
  function isEnforced() {
    try { return parseInt(localStorage.getItem("__abm_yt_enforced") || "0", 10) > Date.now(); }
    catch (e) { return false; }
  }
  function shouldEngage() {
    const id = watchVideoId();
    return enabled && (forceAlways || isEnforced()) && id !== reclaimBackoffId;
  }

  // youtube-main.js posts this the instant it sees a 403 — engage right away (covers SPA
  // navigations to an enforced video where there's no reload to re-trigger us from the flag).
  window.addEventListener("message", (e) => {
    if (e.source === window && e.data && e.data.__abmYTEnforced && enabled) {
      const id = watchVideoId();
      if (id) { ensurePrefetch(id); sync(); }
    }
  });

  function log() {
    const args = Array.prototype.slice.call(arguments);
    console.log.apply(console, ["[ABM-Reclaim]", ...args]);
    // Mirror to the companion so lines are captured to a file on disk (no copy-paste to debug).
    try {
      const msg = args.map((a) => (a && typeof a === "object") ? JSON.stringify(a) : String(a)).join(" ");
      fetch(BASE + "/log?m=" + encodeURIComponent(msg), { keepalive: true }).catch(() => {});
    } catch (e) {}
  }

  // All companion traffic goes through the background worker: a web page (especially on
  // Brave) can't reach 127.0.0.1 directly (mixed-content / PNA / Brave localhost block), but
  // the worker has the extension origin + host permissions and can.
  function companionReq(path, v, fresh) {
    return new Promise((resolve) => {
      try {
        chrome.runtime.sendMessage({ type: "companion", path, v, port: PORT, fresh: !!fresh }, (resp) => {
          if (chrome.runtime.lastError) {
            resolve({ ok: false, error: chrome.runtime.lastError.message });
            return;
          }
          resolve(resp || { ok: false, error: "no response" });
        });
      } catch (e) {
        resolve({ ok: false, error: String(e) });
      }
    });
  }

  // Fire the resolve (yt-dlp extraction) the instant we know the video id — in parallel with
  // YouTube's own page load — and reuse that in-flight promise when we mount. Extraction is the
  // slow part (~1s); overlapping it with page render is most of the perceived speed-up. The
  // resolve also doubles as the companion health check (companion down → error → silent no-op).
  function ensurePrefetch(id) {
    if (prefetch.id !== id || !prefetch.p) {
      prefetch = { id, p: companionReq("resolve", id) };
      log("prefetch resolve", id);
    }
    return prefetch.p;
  }

  // ---- url / nav ----------------------------------------------------------
  function watchVideoId() {
    try {
      const u = new URL(location.href);
      if (u.pathname === "/watch") return u.searchParams.get("v");
    } catch (e) {}
    return null;
  }

  // ---- volume memory -------------------------------------------------------
  // Our <video> is a fresh element → defaults to 100%. Mirror YouTube's own saved
  // volume (localStorage "yt-player-volume") so playback starts where the user left it,
  // and write changes back so our player and YouTube's stay in sync.
  function readYTVolume() {
    try {
      const raw = localStorage.getItem("yt-player-volume");
      if (!raw) return null;
      const d = JSON.parse(JSON.parse(raw).data);
      if (typeof d.volume === "number")
        return { volume: Math.max(0, Math.min(1, d.volume / 100)), muted: !!d.muted };
    } catch (e) {}
    return null;
  }
  function writeYTVolume(vol, muted) {
    try {
      const data = JSON.stringify({ volume: Math.round(vol * 100), muted: !!muted });
      const now = Date.now();
      localStorage.setItem("yt-player-volume",
        JSON.stringify({ data, creation: now, expiration: now + 30 * 24 * 3600 * 1000 }));
    } catch (e) {}
  }
  function applyVolume(video) {
    const v = readYTVolume();
    if (v) { try { video.volume = v.volume; video.muted = v.muted; } catch (e) {} }
    if (!video.__abmVolBound) {
      video.__abmVolBound = true;
      video.addEventListener("volumechange", () => writeYTVolume(video.volume, video.muted));
    }
  }

  // ---- playback speed ------------------------------------------------------
  // Our <video> covers YouTube's player, so its native speed menu is unreachable.
  const RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
  function readRate() {
    try {
      const r = parseFloat(localStorage.getItem("abm-reclaim-rate"));
      if (isFinite(r) && r > 0 && r <= 4) return r;
    } catch (e) {}
    return 1;
  }
  function applyRate(video) {
    const r = readRate();
    // defaultPlaybackRate too: the media load algorithm resets playbackRate to it on every
    // re-feed (quality switch / progressive fallback), which would silently snap back to 1x.
    try { video.defaultPlaybackRate = r; video.playbackRate = r; } catch (e) {}
    if (!video.__abmRateBound) {
      video.__abmRateBound = true;
      video.addEventListener("loadedmetadata", () => {
        const want = readRate();
        try { if (video.playbackRate !== want) video.playbackRate = want; } catch (e) {}
      });
    }
  }
  function setRate(r) {
    const video = cur && cur.video; if (!video) return;
    try { localStorage.setItem("abm-reclaim-rate", String(r)); } catch (e) {}
    try { video.defaultPlaybackRate = r; video.playbackRate = r; } catch (e) {}
    showToast(r === 1 ? "Normal speed" : r + "x speed");
    const panel = document.getElementById("abm-reclaim-menu");
    if (panel) markMenu(panel);
  }
  // Step through the ladder with YouTube's own shortcuts (shift+. / shift+,).
  function stepRate(dir) {
    const i = RATES.indexOf(readRate());
    const next = i === -1 ? 1 : RATES[Math.min(RATES.length - 1, Math.max(0, i + dir))];
    setRate(next);
  }
  function bindRateKeys() {
    if (window.__abmRateKeys) return;
    window.__abmRateKeys = true;
    document.addEventListener("keydown", (e) => {
      if (!cur || !window.__abmReclaimActive) return;
      if (!e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
      const el = document.activeElement;
      if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return; // YT search box
      if (e.key === ">" || e.key === ".") { e.preventDefault(); stepRate(1); }
      else if (e.key === "<" || e.key === ",") { e.preventDefault(); stepRate(-1); }
    }, true);
  }

  // ---- seek handling -------------------------------------------------------
  // The HD (MSE) stream is fed start-to-finish and can't jump to an arbitrary time
  // (no byte index). So when the user seeks PAST what's buffered, swap to the
  // progressive stream, which the browser can natively range-seek to any point.
  // Back-seeks / in-buffer seeks need no help. Once on progressive, native seeking
  // handles everything, so we don't re-trigger.
  let __seekFixing = false;
  let __seekPending = false;
  function bufferedCovers(video, t) {
    try {
      const b = video.buffered;
      for (let i = 0; i < b.length; i++)
        if (t >= b.start(i) - 0.6 && t <= b.end(i) + 0.6) return true;
    } catch (e) {}
    return false;
  }
  function onSeek(video) {
    if (!cur || video.__abmProgressive) return;        // progressive: browser seeks natively
    if (!video.__abmReady) return;                     // not playing yet → ignore load-time seeks
    let bufLen = 0; try { bufLen = video.buffered.length; } catch (e) {}
    if (!bufLen) return;                               // nothing buffered yet → not a real skip
    const t = video.currentTime || 0;
    if (bufferedCovers(video, t)) return;              // within buffer → browser handles it
    if (__seekFixing) { __seekPending = true; return; } // a feed is mid-flight → coalesce (drag/rapid seeks)
    __seekFixing = true;
    log("seek beyond buffer ->", Math.round(t) + "s");
    (async () => {
      try {
        // Preferred: jump inside the HD stream via its segment index — instant + stays HD.
        // If the background prefetch hasn't finished, fetch the index ON-DEMAND and await it
        // (~200ms) rather than dropping to a low-res/flaky fallback.
        let ok = false;
        if (canMSEpair(cur.vFmt, cur.aFmt)) {
          const haveIdx = (cur.vIdx && cur.aIdx) || await ensureHDIndex(cur).catch(() => false);
          if (haveIdx && cur.vIdx && cur.aIdx) {
            log("HD in-stream seek to", Math.round(t) + "s");
            ok = await feedHD(cur.video, cur.vFmt, cur.aFmt, cur.id, t,
                              { vIdx: cur.vIdx, aIdx: cur.aIdx })
              .catch((e) => { log("HD seek threw", e && e.message); return false; });
          }
        }
        if (!ok) {
          // Fallback: natively-seekable progressive stream. Only a REAL progressive (muxed)
          // format — never data.video, a video-only DASH stream the browser can't play as a
          // plain file (= freeze).
          const prog = cur.data && cur.data.progressive;
          if (prog && prog.url) feedProgressive(cur.video, cur.data, t);
          else log("seek: HD unavailable and no progressive fallback");
        }
      } finally {
        __seekFixing = false;
        // If more seeks landed mid-feed (a scrubber drag) and the playhead still isn't
        // covered, run once more against the LATEST position so we converge on where the
        // user actually let go.
        if (__seekPending) {
          __seekPending = false;
          if (!video.__abmProgressive && !bufferedCovers(video, video.currentTime || 0)) onSeek(video);
        }
      }
    })();
  }

  // Our player ending doesn't trigger YouTube's auto-advance (it never saw its own
  // video end). Click the player's Next control, which handles both playlists and the
  // autoplay up-next, so playback continues to the next video like normal.
  function onEnded(video) {
    if (!cur || watchVideoId() !== cur.id) return;
    // Only a GENUINE end: a real-duration video whose playhead actually reached the end.
    // Guards against spurious early "ended" events (MSE init, load errors) that would
    // otherwise machine-gun through a playlist from the first second.
    const d = video.duration;
    if (!d || !isFinite(d) || d < 1) return;
    if (video.currentTime < d - 2) {
      // A truncated HD proxy response can make MSE end without throwing even though the
      // real video has plenty left. Recover at the same playhead instead of leaving a
      // permanently ended/frozen custom player on screen.
      if (!video.__abmProgressive) {
        recoverHDToProgressive(video, cur.id, video.__abmFeedGeneration, "HD stream ended early");
      }
      return;
    }
    if (video.__abmEndedAt === cur.id) return;          // already advanced from this video
    video.__abmEndedAt = cur.id;
    const next = document.querySelector(".ytp-next-button");
    if (next && next.getAttribute("aria-disabled") !== "true" && !next.hasAttribute("disabled")) {
      log("video ended -> next");
      next.click();
    }
  }

  // ---- native player suppression ------------------------------------------
  // Keep YouTube's own <video> muted + paused so its (ad-enforced) stream never plays
  // audio in the background while our player is on top.
  let suppressIv = null;
  function suppressNative() {
    if (suppressIv) return;
    suppressIv = setInterval(() => {
      document.querySelectorAll("video.html5-main-video, #movie_player video").forEach((v) => {
        if (v.id === PLAYER_ID) return;
        try { v.muted = true; if (!v.paused) v.pause(); } catch (e) {}
      });
    }, 400);
  }
  function unsuppressNative() {
    if (suppressIv) { clearInterval(suppressIv); suppressIv = null; }
    document.querySelectorAll("video.html5-main-video, #movie_player video").forEach((v) => {
      if (v.id === PLAYER_ID) return;
      try { v.muted = false; } catch (e) {}
    });
  }

  // ---- our player ----------------------------------------------------------
  function findStage() {
    return document.querySelector("#movie_player") ||
           document.querySelector(".html5-video-player");
  }

  function injectStyleOnce() {
    if (document.getElementById("abm-reclaim-style")) return;
    const s = document.createElement("style");
    s.id = "abm-reclaim-style";
    s.textContent = [
      // Hide every native surface underneath us: the native <video>, its container, the
      // "an error occurred" 403 screen, the cued thumbnail, spinner and ad overlays. Our
      // player then sits on top at a z-index above all of YouTube's own player chrome.
      "#movie_player.abm-reclaimed video.html5-main-video,",
      "#movie_player.abm-reclaimed .html5-video-container { visibility:hidden !important; }",
      "#movie_player.abm-reclaimed .ytp-error,",
      "#movie_player.abm-reclaimed .ytp-cued-thumbnail-overlay,",
      "#movie_player.abm-reclaimed .ytp-spinner,",
      "#movie_player.abm-reclaimed .ad-showing .video-ads,",
      "#movie_player.abm-reclaimed .ytp-ad-module { display:none !important; }",
      "#" + PLAYER_ID + "{position:absolute;inset:0;width:100%;height:100%;z-index:2147483000;" +
        "background:#000;object-fit:contain;}",
      "#abm-reclaim-badge{position:absolute;top:10px;left:12px;z-index:2147483002;background:rgba(20,8,40,.82);" +
        "color:#e9ddff;border:1px solid #7a3cff;border-radius:999px;padding:4px 11px;font:600 11px/1 Roboto,Arial,sans-serif;" +
        "pointer-events:none;transition:opacity .5s;letter-spacing:.02em;display:flex;gap:6px;align-items:center;}",
      "#abm-reclaim-badge.abm-fade{opacity:0;}",
      "#abm-reclaim-msg{position:absolute;inset:0;z-index:2147483001;display:flex;align-items:center;justify-content:center;" +
        "text-align:center;color:#cdbbff;background:#000;font:14px/1.5 Roboto,Arial,sans-serif;padding:0 24px;}",
      "#abm-reclaim-toast{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:2147483002;" +
        "background:rgba(20,8,40,.9);color:#f3ecff;border:1px solid #7a3cff;border-radius:8px;padding:7px 14px;" +
        "font:600 12px/1 Roboto,Arial,sans-serif;opacity:0;transition:opacity .3s;pointer-events:none;}",
      // Custom control bar — native <video controls> won't reliably draw a seek bar over a
      // still-streaming MSE source, so we render our own (play/seek/time/volume/fullscreen).
      "#movie_player.abm-reclaimed{cursor:auto;}",
      "#abm-reclaim-controls{position:absolute;left:0;right:0;bottom:0;z-index:2147483004;box-sizing:border-box;" +
        "display:flex;align-items:center;gap:10px;padding:14px 14px 9px;" +
        "background:linear-gradient(0deg,rgba(0,0,0,.78),rgba(0,0,0,0));" +
        "font:12px Roboto,Arial,sans-serif;color:#fff;opacity:1;transition:opacity .25s;}",
      "#movie_player.abm-reclaimed.abm-hide-ctrl #abm-reclaim-controls{opacity:0;pointer-events:none;}",
      "#abm-reclaim-controls button{background:none;border:none;color:#fff;cursor:pointer;font-size:16px;" +
        "line-height:1;padding:4px 5px;border-radius:4px;flex:none;}",
      "#abm-reclaim-controls button:hover{background:rgba(255,255,255,.16);}",
      "#abm-reclaim-controls .abm-rc-time{font-variant-numeric:tabular-nums;white-space:nowrap;font-size:12px;color:#eee;flex:none;}",
      "#abm-rc-seek{position:relative;flex:1 1 auto;min-width:60px;height:15px;cursor:pointer;display:flex;align-items:center;}",
      "#abm-rc-seek .abm-rc-track{position:absolute;left:0;right:0;height:4px;background:rgba(255,255,255,.28);border-radius:2px;}",
      "#abm-rc-seek .abm-rc-buf{position:absolute;left:0;height:4px;background:rgba(255,255,255,.5);border-radius:2px;width:0;}",
      "#abm-rc-seek .abm-rc-play{position:absolute;left:0;height:4px;background:#7a3cff;border-radius:2px;width:0;}",
      "#abm-rc-seek .abm-rc-thumb{position:absolute;left:0;width:13px;height:13px;border-radius:50%;background:#7a3cff;" +
        "transform:translateX(-50%);box-shadow:0 0 3px rgba(0,0,0,.6);opacity:0;transition:opacity .15s;}",
      "#abm-rc-seek:hover .abm-rc-thumb,#abm-rc-seek.abm-drag .abm-rc-thumb{opacity:1;}",
      "#abm-rc-vol{width:74px;height:4px;-webkit-appearance:none;appearance:none;background:rgba(255,255,255,.4);" +
        "border-radius:2px;cursor:pointer;flex:none;}",
      "#abm-rc-vol::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:#fff;cursor:pointer;}",
      "#abm-reclaim-controls .abm-rc-theater[aria-pressed='true']{color:#e9ddff;background:rgba(122,60,255,.28);}",
      "#abm-reclaim-menu-wrap{position:absolute;top:8px;right:10px;z-index:2147483005;font:13px Roboto,Arial,sans-serif;}",
      "#abm-reclaim-gear{width:34px;height:34px;border-radius:8px;border:1px solid rgba(122,60,255,.6);" +
        "background:rgba(20,8,40,.78);color:#e9ddff;font-size:16px;cursor:pointer;line-height:1;opacity:.55;transition:opacity .2s;}",
      "#abm-reclaim-menu-wrap:hover #abm-reclaim-gear{opacity:1;}",
      "#abm-reclaim-menu{position:absolute;top:40px;right:0;min-width:150px;max-height:60vh;overflow:auto;" +
        "background:rgba(16,8,30,.97);border:1px solid #7a3cff;border-radius:10px;padding:6px;box-shadow:0 10px 34px rgba(0,0,0,.6);}",
      "#abm-reclaim-menu .abm-mh{color:#9a86c8;font-size:10px;letter-spacing:.08em;text-transform:uppercase;padding:7px 8px 3px;}",
      "#abm-reclaim-menu .abm-mi{display:block;width:100%;text-align:left;background:none;border:none;color:#e9ddff;" +
        "padding:7px 9px;border-radius:6px;cursor:pointer;font-size:13px;}",
      "#abm-reclaim-menu .abm-mi:hover{background:rgba(122,60,255,.25);}",
      "#abm-reclaim-menu .abm-mi.abm-active{background:rgba(122,60,255,.4);font-weight:700;}",
      "#abm-reclaim-menu .abm-mi.abm-active:after{content:' ✓';color:#bca8ff;}",
    ].join("");
    (document.head || document.documentElement).appendChild(s);
  }

  function setMessage(stage, html) {
    let m = stage.querySelector("#abm-reclaim-msg");
    if (!html) { if (m) m.remove(); return; }
    if (!m) {
      m = document.createElement("div");
      m.id = "abm-reclaim-msg";
      stage.appendChild(m);
    }
    m.innerHTML = html;
  }

  // Wait for YouTube's player container to exist (it appears after our document_start prefetch).
  function waitForStage(id) {
    return new Promise((resolve) => {
      const t0 = Date.now();
      const tick = () => {
        if (watchVideoId() !== id) return resolve(null);
        const s = findStage();
        if (s) return resolve(s);
        if (Date.now() - t0 > 20000) return resolve(null);
        setTimeout(tick, 100);
      };
      tick();
    });
  }

  async function mount(id, resolvePromise) {
    // Kick the resolve and the DOM wait off together; the resolve is usually already in flight
    // from document_start, so by the time the player container exists the streams are ready.
    const rp = resolvePromise || ensurePrefetch(id);
    const stage = await waitForStage(id);
    if (!stage || watchVideoId() !== id) return;
    injectStyleOnce();
    stage.classList.add("abm-reclaimed");
    window.__abmReclaimActive = true; // tells youtube.js to stand down on the native player
    setMessage(stage, "Loading ad-free stream…");

    const rr = await rp;
    const data = rr && rr.ok ? rr.data : { ok: false, error: (rr && rr.error) || "resolve failed" };
    if (watchVideoId() !== id) return; // user navigated away mid-resolve

    if (!data || !data.ok) {
      // extraction failed — back off to the native player + safeguard rather than black-screen
      log("resolve failed", data && data.error);
      teardown();
      return;
    }

    if (!data.hls && !(data.progressive && data.progressive.url) && !(data.video && data.video.url)) {
      teardown(); return;
    }

    // Build (or reuse) our video element.
    let video = document.getElementById(PLAYER_ID);
    if (!video) {
      video = document.createElement("video");
      video.id = PLAYER_ID;
      video.controls = false; // custom control bar (buildControls) — native controls miss the seek bar over MSE
      video.autoplay = true;
      video.setAttribute("playsinline", "");
      video.addEventListener("playing", () => {
        video.__abmReady = true;
        video.__abmRecovering = false;
        clearProgressiveWatch(video);
        resetFrameWatch(video, true);
        log("VIDEO PLAYING ✓");
      });
      video.addEventListener("seeking", () => onSeek(video));
      video.addEventListener("ended", () => onEnded(video));
      stage.appendChild(video);
    }
    clearProgressiveWatch(video);
    video.__abmFreshResolveTried = false;
    applyVolume(video); // start at the user's saved YouTube volume, not 100%
    applyRate(video);   // and the user's saved speed — the native speed menu is behind our player
    bindRateKeys();
    bindStallWatchdog(video);
    buildControls(stage, video); // our own seek/play/volume bar (works for both MSE and HLS)

    setMessage(stage, "");
    // We're serving this video ad-free — clear any stale server-side-enforcement pause flag so
    // youtube.js / the popup don't show the "paused on this account" notice over a working video.
    try { localStorage.removeItem("__abm_yt_enforced"); } catch (e) {}

    if (!stage.querySelector("#abm-reclaim-badge")) {
      const b = document.createElement("div");
      b.id = "abm-reclaim-badge";
      b.textContent = "🛡️ ad-free";
      stage.appendChild(b);
      setTimeout(() => b.classList.add("abm-fade"), 3500);
    }

    suppressNative();
    mountedId = id;

    // Track the current selection so the quality/caption menu can re-feed without a remount.
    cur = { id, video, data, vFmt: data.video, aFmt: data.audio, capLang: null };

    // Live / just-ended streams come back as HLS (data.hls) — play them via hls.js, which
    // owns the live edge, DVR seeking, segment fetching + transmux. Normal videos take the
    // existing single-file MSE/progressive path untouched.
    if (data.hls) {
      const b = document.getElementById("abm-reclaim-badge");
      if (b) b.textContent = data.isLive ? "🛡️ ad-free · LIVE" : "🛡️ ad-free";
      playHls(video, data.hls);
      return;
    }
    buildMenu(stage, data, video);
    await playSelection(0);
  }

  // ---- HLS (live / just-ended) playback via hls.js ------------------------
  function playHls(video, src) {
    cleanupMSE(video);
    video.__abmProgressive = false;
    video.__abmHls = { native: true };

    const HlsLib = window.Hls;
    if (HlsLib && HlsLib.isSupported()) {
      // enableWorker:false — content-script workers are unreliable under MV3; transmux on
      // the main thread (still light) is the safe choice for the live path.
      const hls = new HlsLib({ enableWorker: false, lowLatencyMode: false, backBufferLength: 90 });
      video.__abmHls = hls;
      hls.on(HlsLib.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); });
      hls.on(HlsLib.Events.ERROR, (_e, d) => {
        if (!d || !d.fatal) return;
        log("hls fatal", d.type, d.details);
        if (d.type === HlsLib.ErrorTypes.NETWORK_ERROR) hls.startLoad();
        else if (d.type === HlsLib.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
        else { try { hls.destroy(); } catch (e) {} video.__abmHls = null; }
      });
      hls.loadSource(src);
      hls.attachMedia(video);
      log("HLS via hls.js", src.slice(0, 48));
    } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src; // Safari-style native HLS
      video.play().catch(() => {});
      log("HLS via native player");
    } else {
      log("HLS unsupported — backing off to native");
      teardown();
    }
  }

  // Feed the current selection (HD via MSE, falling back to progressive). seekTo restores the
  // playhead after a quality switch. Never regresses below a playing video.
  async function playSelection(seekTo) {
    if (!cur) return;
    const { video, vFmt, aFmt, data, id } = cur;
    let started = false;
    if (canMSEpair(vFmt, aFmt)) {
      log("play HD", (vFmt.height || "?") + "p", vFmt.vcodec, "+", aFmt.acodec);
      started = await feedHD(video, vFmt, aFmt, id, seekTo).catch((e) => { log("HD threw", e && e.message); return false; });
    }
    if (!started) {
      if (watchVideoId() !== id) return;
      feedProgressive(video, data, seekTo);
      return;
    }
    ensureHDIndex(cur); // background-load the segment index → fast in-stream seeking
  }

  // ---- HD (MSE) + progressive playback ------------------------------------
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }

  function mimeFor(f, isVideo) {
    if (!f) return "";
    const codec = (isVideo ? f.vcodec : f.acodec) || "";
    let cont;
    if (isVideo) cont = /^(vp9|vp09|vp8)/.test(codec) ? "video/webm" : "video/mp4";
    else cont = /^(opus|vorbis)/.test(codec) ? "audio/webm" : "audio/mp4";
    return cont + '; codecs="' + codec + '"';
  }

  function canMSEpair(vFmt, aFmt) {
    if (!(window.MediaSource && vFmt && aFmt && vFmt.url && aFmt.url)) return false;
    try {
      return MediaSource.isTypeSupported(mimeFor(vFmt, true)) &&
             MediaSource.isTypeSupported(mimeFor(aFmt, false));
    } catch (e) { return false; }
  }

  function setBadgeQuality(h) {
    const b = document.getElementById("abm-reclaim-badge");
    if (b && h) b.textContent = "🛡️ ad-free · " + h + "p";
  }

  function bufferedAhead(v) {
    try {
      const buf = v.buffered;
      if (!buf || !buf.length) return 0;
      return buf.end(buf.length - 1) - (v.currentTime || 0);
    } catch (e) { return 0; }
  }

  const STALL_TIMEOUT_MS = 10000;
  const PROGRESSIVE_START_TIMEOUT_MS = 8000;

  function presentedVideoFrames(video) {
    try {
      if (typeof video.getVideoPlaybackQuality !== "function") return null;
      const q = video.getVideoPlaybackQuality();
      const n = q && Number(q.totalVideoFrames);
      return isFinite(n) && n >= 0 ? n : null;
    } catch (e) { return null; }
  }

  function resetFrameWatch(video, playing) {
    const w = video && video.__abmFrameWatch;
    if (!w) return;
    const now = Date.now();
    w.startedAt = playing ? now : 0;
    w.lastFrameAt = now;
    w.seen = false;
    const n = presentedVideoFrames(video);
    if (n !== null) w.presentedFrames = n;
  }

  function notePresentedFrame(video, presentedFrames) {
    const w = video && video.__abmFrameWatch;
    if (!w) return;
    w.seen = true;
    w.lastFrameAt = Date.now();
    if (presentedFrames !== undefined && presentedFrames !== null) {
      const n = Number(presentedFrames);
      if (isFinite(n)) w.presentedFrames = n;
    }
  }

  function samplePresentedFrames(video) {
    const w = video && video.__abmFrameWatch;
    if (!w) return;
    const n = presentedVideoFrames(video);
    if (n === null) return;
    if ((w.presentedFrames === null && n > 0) ||
        (w.presentedFrames !== null && n > w.presentedFrames)) notePresentedFrame(video, n);
    w.presentedFrames = n;
  }

  function bindFrameWatch(video) {
    if (video.__abmFrameWatch) return;
    const w = {
      supported: typeof video.requestVideoFrameCallback === "function" ||
        typeof video.getVideoPlaybackQuality === "function",
      startedAt: 0,
      lastFrameAt: Date.now(),
      seen: false,
      presentedFrames: presentedVideoFrames(video),
      callbackId: null,
    };
    video.__abmFrameWatch = w;

    if (typeof video.requestVideoFrameCallback === "function") {
      const onFrame = (_now, metadata) => {
        if (video.__abmFrameWatch !== w) return;
        notePresentedFrame(video, metadata && metadata.presentedFrames);
        try { w.callbackId = video.requestVideoFrameCallback(onFrame); }
        catch (e) { w.callbackId = null; }
      };
      try { w.callbackId = video.requestVideoFrameCallback(onFrame); } catch (e) {}
    }
  }

  // HD playback is two long-lived proxy streams (video + audio). A connection can die after
  // the first frames have already played, so the startup fallback alone is not enough. Move
  // to the progressive stream at the same playhead exactly once; cleanupMSE aborts the old
  // readers and the generation check prevents a stale stream callback from replacing a newer
  // quality selection or seek.
  function recoverHDToProgressive(video, id, generation, reason) {
    if (!cur || cur.id !== id || cur.video !== video || watchVideoId() !== id) return false;
    if (generation !== undefined && video.__abmFeedGeneration !== generation) return false;
    if (video.__abmProgressive || video.__abmRecovering || video.__abmHls) return false;
    const t = Math.max(0, video.currentTime || 0);
    video.__abmRecovering = true;
    log("HD recovery -> progressive", reason || "stalled", Math.round(t) + "s");
    showToast("Stream recovered at " + fmtTime(t));
    feedProgressive(video, cur.data, t);
    return true;
  }

  function clearProgressiveWatch(video) {
    const w = video && video.__abmProgressiveWatch;
    if (!w) return;
    clearTimeout(w.timer);
    try { video.removeEventListener("error", w.onError); } catch (e) {}
    video.__abmProgressiveWatch = null;
  }

  function nativeFallbackAfterReclaimFailure(video, id, reason) {
    if (!cur || cur.id !== id || cur.video !== video || watchVideoId() !== id) return;
    reclaimBackoffId = id;
    clearProgressiveWatch(video);
    log("Reclaim retry failed -> native playback", reason || "progressive unavailable");
    teardown();
  }

  function freshResolveOnce(video, id, seekTo, reason) {
    if (!cur || cur.id !== id || cur.video !== video || watchVideoId() !== id) return;
    if (video.__abmFreshResolveTried) {
      nativeFallbackAfterReclaimFailure(video, id, reason);
      return;
    }
    video.__abmFreshResolveTried = true;
    clearProgressiveWatch(video);
    const t = Math.max(0, seekTo === undefined ? (video.currentTime || 0) : seekTo);
    log("progressive failed -> fresh resolve", reason || "stale URL", Math.round(t) + "s");
    companionReq("resolve", id, true).then(async (rr) => {
      if (!cur || cur.id !== id || cur.video !== video || watchVideoId() !== id) return;
      const data = rr && rr.ok ? rr.data : null;
      if (!data || !data.ok) {
        nativeFallbackAfterReclaimFailure(video, id, (data && data.error) || (rr && rr.error) || "resolve failed");
        return;
      }
      if (!data.hls && !(data.progressive && data.progressive.url) && !(data.video && data.video.url)) {
        nativeFallbackAfterReclaimFailure(video, id, "fresh resolve returned no playable stream");
        return;
      }
      cur.data = data;
      cur.vFmt = data.video;
      cur.aFmt = data.audio;
      cur.vIdx = null;
      cur.aIdx = null;
      cur.__idxPromise = null;
      cur.capLang = null;
      closeMenuGlobal();
      const stage = findStage();
      if (stage && !data.hls) buildMenu(stage, data, video);
      if (data.hls) playHls(video, data.hls);
      else await playSelection(t);
    }).catch((e) => {
      nativeFallbackAfterReclaimFailure(video, id, "fresh resolve error: " + (e && e.message || e));
    });
  }

  function requestVideoPlay(video, reason) {
    const p = video.play();
    if (!p || typeof p.catch !== "function") return;
    p.catch((e) => {
      const name = e && e.name || String(e || "unknown");
      log("progressive play rejected", name);
      if (video.__abmProgressive && cur && cur.video === video &&
          (video.error || name === "NotSupportedError")) {
        freshResolveOnce(video, cur.id, video.currentTime || 0, reason || ("play rejected: " + name));
      }
    });
  }

  function watchProgressiveStartup(video, id, attempt, seekTo) {
    clearProgressiveWatch(video);
    const onError = () => {
      if (!cur || cur.id !== id || cur.video !== video || video.__abmProgressiveAttempt !== attempt) return;
      freshResolveOnce(video, id, seekTo, "progressive media error");
    };
    const timer = setTimeout(() => {
      if (!cur || cur.id !== id || cur.video !== video || video.__abmProgressiveAttempt !== attempt) return;
      // A normal autoplay refusal can leave the element paused even though its source is
      // healthy. Only refresh when the source never became usable or already reports an error.
      if (video.error || video.readyState < 2) onError();
      else clearProgressiveWatch(video);
    }, PROGRESSIVE_START_TIMEOUT_MS);
    video.__abmProgressiveWatch = { timer, onError, attempt };
    video.addEventListener("error", onError, { once: true });
  }

  // Catch silent MSE starvation too: fetch/read failures normally throw, but a socket can also
  // stop producing bytes without closing. Only recover while playback is expected to advance,
  // and give normal buffering ten full seconds before intervening.
  function bindStallWatchdog(video) {
    if (video.__abmStallIv) return;
    bindFrameWatch(video);
    const frameWatch = video.__abmFrameWatch;
    let lastTime = video.currentTime || 0;
    let lastAdvanceAt = Date.now();
    const reset = () => {
      lastTime = video.currentTime || 0;
      lastAdvanceAt = Date.now();
      resetFrameWatch(video, !video.paused);
    };
    video.addEventListener("playing", reset);
    video.addEventListener("seeked", reset);
    video.__abmStallIv = setInterval(() => {
      if (!cur || cur.video !== video || watchVideoId() !== cur.id ||
          video.paused || video.ended || video.seeking ||
          video.__abmProgressive || video.__abmRecovering || video.__abmHls) {
        reset();
        return;
      }
      const now = Date.now();
      const visible = document.visibilityState === "visible";
      if (!visible) {
        // requestVideoFrameCallback is allowed to pause in a background tab. Do not
        // lower quality for a frame clock that the browser intentionally throttled.
        if (frameWatch) { frameWatch.startedAt = 0; frameWatch.lastFrameAt = now; }
      } else if (frameWatch && !frameWatch.startedAt && video.__abmReady) {
        // Returning to the tab may not emit another "playing" event. Give the frame
        // clock a fresh ten-second window in that case.
        resetFrameWatch(video, true);
      }
      samplePresentedFrames(video);
      const t = video.currentTime || 0;
      if (Math.abs(t - lastTime) >= 0.2) {
        lastTime = t;
        lastAdvanceAt = Date.now();
      }
      const frameStalled = visible && frameWatch && frameWatch.startedAt &&
        now - frameWatch.lastFrameAt >= STALL_TIMEOUT_MS;
      const noDecodedVideo = visible && frameWatch && !frameWatch.supported &&
        frameWatch.startedAt && now - frameWatch.lastFrameAt >= STALL_TIMEOUT_MS &&
        (!video.videoWidth || !video.videoHeight);
      const playheadStalled = now - lastAdvanceAt >= STALL_TIMEOUT_MS;
      if (!frameStalled && !noDecodedVideo && !playheadStalled) return;
      const generation = video.__abmFeedGeneration;
      const reason = (frameStalled || noDecodedVideo)
        ? "video frames stalled; presented=" + (frameWatch.seen ? "yes" : "none") +
          "; size=" + (video.videoWidth || 0) + "x" + (video.videoHeight || 0)
        : "playhead stalled; buffered=" + Math.max(0, bufferedAhead(video)).toFixed(1) + "s";
      if (recoverHDToProgressive(video, cur.id, generation, reason)) {
        reset();
      }
    }, 1000);
  }

  // A per-SourceBuffer append queue. Each push() resolves when its chunk has been appended,
  // which gives natural backpressure. On QuotaExceededError (long videos) it evicts buffer
  // well behind the playhead and retries — a sliding window so memory stays bounded.
  function makeAppender(sb, video) {
    const q = [];
    let removing = false;
    function schedule() {
      if (sb.updating || removing || !q.length) return;
      const item = q[0];
      try {
        sb.__cur = item;
        sb.appendBuffer(item.chunk);
      } catch (e) {
        sb.__cur = null;
        if (e && e.name === "QuotaExceededError") {
          const ct = video.currentTime || 0;
          try {
            if (sb.buffered.length) {
              const s = sb.buffered.start(0);
              const end = ct - 10;
              if (end > s) { removing = true; sb.remove(s, end); return; }
            }
          } catch (e2) {}
          setTimeout(schedule, 300);
          return;
        }
        q.shift(); item.reject(e); schedule();
      }
    }
    sb.addEventListener("updateend", () => {
      if (removing) { removing = false; schedule(); return; }
      const it = sb.__cur;
      if (it) { sb.__cur = null; q.shift(); it.resolve(); }
      schedule();
    });
    return {
      push(chunk) { return new Promise((resolve, reject) => { q.push({ chunk, resolve, reject }); schedule(); }); },
      get busy() { return sb.updating || q.length > 0; },
    };
  }

  async function streamInto(url, appender, video, isAborted, rangeStart) {
    const opts = rangeStart ? { headers: { Range: "bytes=" + rangeStart + "-" } } : undefined;
    const res = await fetch(BASE + "/proxy?u=" + encodeURIComponent(url), opts);
    if (!res.ok || !res.body) throw new Error("proxy HTTP " + res.status);
    const reader = res.body.getReader();
    while (true) {
      if (isAborted()) { try { await reader.cancel(); } catch (e) {} return; }
      const { done, value } = await reader.read();
      if (done) return;
      // don't race too far ahead of the playhead
      let guard = 0;
      while (bufferedAhead(video) > 90 && !isAborted() && guard++ < 2400) await sleep(250);
      if (isAborted()) { try { await reader.cancel(); } catch (e) {} return; }
      await appender.push(value);
    }
  }

  // Fetch one byte range and append it once — used for the fMP4 init segment when seeking.
  async function appendRange(url, appender, start, end) {
    const res = await fetch(BASE + "/proxy?u=" + encodeURIComponent(url),
      { headers: { Range: "bytes=" + start + "-" + end } });
    if (!(res.ok || res.status === 206 || res.status === 200)) throw new Error("init HTTP " + res.status);
    await appender.push(new Uint8Array(await res.arrayBuffer()));
  }

  // Binary-search a segment index for the segment covering time t → [timeSec, byteStart].
  function segAt(idx, t) {
    const s = idx && idx.segments;
    if (!s || !s.length) return null;
    let lo = 0, hi = s.length - 1, ans = 0;
    while (lo <= hi) { const m = (lo + hi) >> 1; if (s[m][0] <= t) { ans = m; lo = m + 1; } else hi = m - 1; }
    return s[ans];
  }

  // Pull the HD segment indexes (video + audio) in the background so a forward seek can jump
  // straight into the HD stream (instant, stays HD) instead of reloading a low-res stream.
  function fetchIndex(url) {
    return fetch(BASE + "/index?u=" + encodeURIComponent(url))
      .then((r) => { if (!r.ok) { log("index HTTP", r.status); return null; } return r.json(); })
      .then((j) => (j && j.ok ? j : (log("index not-ok", j && j.error), null)))
      .catch((e) => { log("index fetch error", e && e.message); return null; });
  }
  // Load the HD segment indexes (video + audio). Returns a Promise<bool> that resolves true
  // once BOTH are present, so a seek can await it on-demand. Safe to call repeatedly: it never
  // poisons an already-good index with a failed fetch (the old bug — one transient /index miss
  // permanently disabled HD seeking), it keeps each track's index independently, and it retries
  // on the next call instead of giving up forever.
  function ensureHDIndex(c) {
    if (!c) return Promise.resolve(false);
    if (c.vIdx && c.aIdx) return Promise.resolve(true);
    if (!(c.vFmt && c.vFmt.url && c.aFmt && c.aFmt.url)) { log("HD index: no fmt urls"); return Promise.resolve(false); }
    if (c.__idxPromise) return c.__idxPromise;          // a fetch is already in flight → share it
    const vu = c.vFmt.url, au = c.aFmt.url;
    log("fetching HD seek index…");
    c.__idxPromise = Promise.all([
      c.vIdx ? Promise.resolve(c.vIdx) : fetchIndex(vu),
      c.aIdx ? Promise.resolve(c.aIdx) : fetchIndex(au),
    ]).then(([v, a]) => {
      if (cur === c && c.vFmt.url === vu && c.aFmt.url === au) {
        if (v) c.vIdx = v;                              // keep each good index; never null a good one
        if (a) c.aIdx = a;
        if (c.vIdx && c.aIdx) log("HD seek index ready:", c.vIdx.segments.length, "+", c.aIdx.segments.length, "segs");
        else log("HD index incomplete:", v ? "video ok" : "video FAILED", "/", a ? "audio ok" : "audio FAILED", "(will retry on next seek)");
      }
      return !!(c.vIdx && c.aIdx);
    }).finally(() => { c.__idxPromise = null; });
    return c.__idxPromise;
  }

  function cleanupMSE(video) {
    clearProgressiveWatch(video);
    try { if (video.__abmHls && typeof video.__abmHls.destroy === "function") video.__abmHls.destroy(); }
    catch (e) {}
    video.__abmHls = null;
    try { if (video.__abmAbort) video.__abmAbort(); } catch (e) {}
    video.__abmAbort = null;
    const ms = video.__abmMS;
    if (ms) { try { if (ms.readyState === "open") ms.endOfStream(); } catch (e) {} }
    video.__abmMS = null;
  }

  function feedHD(video, vFmt, aFmt, id, seekTo, idx) {
    return new Promise((resolve) => {
      let settled = false;
      let playbackStarted = false;
      const done = (ok) => { if (!settled) { settled = true; resolve(ok); } };

      cleanupMSE(video);
      const generation = (video.__abmFeedGeneration || 0) + 1;
      video.__abmFeedGeneration = generation;
      video.__abmProgressive = false; // HD/MSE stream: can't natively seek past buffer
      resetFrameWatch(video, !video.paused);
      let aborted = false;
      video.__abmAbort = () => { aborted = true; };
      const isAborted = () => aborted || watchVideoId() !== id;

      const ms = new MediaSource();
      video.__abmMS = ms;
      const objUrl = URL.createObjectURL(ms);
      video.src = objUrl;

      const failOnce = () => {
        if (playbackStarted) {
          recoverHDToProgressive(video, id, generation, "HD media error");
        } else {
          done(false);
        }
      };
      ms.addEventListener("error", failOnce);
      video.addEventListener("error", failOnce, { once: true });
      if (seekTo) video.addEventListener("loadedmetadata",
        () => { try { video.currentTime = seekTo; } catch (e) {} }, { once: true });
      // first frames painted → success
      video.addEventListener("playing", () => {
        playbackStarted = true;
        setBadgeQuality(vFmt.height);
        done(true);
      }, { once: true });
      // if nothing plays within 9s, treat HD as failed and fall back
      setTimeout(() => done(false), 9000);

      ms.addEventListener("sourceopen", async () => {
        try { URL.revokeObjectURL(objUrl); } catch (e) {}
        if (isAborted()) return done(false);
        let vSB, aSB;
        try {
          vSB = ms.addSourceBuffer(mimeFor(vFmt, true));
          aSB = ms.addSourceBuffer(mimeFor(aFmt, false));
        } catch (e) { log("addSourceBuffer failed", e && e.message); return done(false); }

        const vApp = makeAppender(vSB, video);
        const aApp = makeAppender(aSB, video);

        // Set the stream duration so seeking to a high timestamp lands inside the seekable
        // range (a ranged seek jumps the playhead far ahead of byte 0).
        if (cur && cur.data && cur.data.duration && !cur.data.isLive) {
          try { ms.duration = cur.data.duration; } catch (e) {}
        }

        // Ranged seek: append the fMP4 init segment, then stream from the target segment's
        // byte offset — jumping straight into the HD stream instead of streaming from 0.
        let vStart = 0, aStart = 0;
        if (idx && idx.vIdx && idx.aIdx && seekTo) {
          const vSeg = segAt(idx.vIdx, seekTo), aSeg = segAt(idx.aIdx, seekTo);
          if (vSeg && aSeg) {
            try {
              await appendRange(vFmt.url, vApp, 0, idx.vIdx.initEnd - 1);
              await appendRange(aFmt.url, aApp, 0, idx.aIdx.initEnd - 1);
            } catch (e) { log("seek init append failed", e && e.message); return done(false); }
            vStart = vSeg[1]; aStart = aSeg[1];
          }
        }

        let vEnded = false, aEnded = false;
        const maybeEnd = () => {
          if (!(vEnded && aEnded)) return;
          if (ms.readyState !== "open") return;
          if (vSB.updating || aSB.updating || vApp.busy || aApp.busy) { setTimeout(maybeEnd, 250); return; }
          try { ms.endOfStream(); } catch (e) {}
        };

        video.play().catch(() => {});
        try {
          await Promise.all([
            streamInto(vFmt.url, vApp, video, isAborted, vStart).then(() => { vEnded = true; maybeEnd(); }),
            streamInto(aFmt.url, aApp, video, isAborted, aStart).then(() => { aEnded = true; maybeEnd(); }),
          ]);
        } catch (e) {
          log("HD stream error", e && e.message);
          if (playbackStarted) {
            recoverHDToProgressive(video, id, generation,
              "HD stream error: " + (e && e.message || "unknown"));
          } else {
            done(false);
          }
          return;
        }
      });
    });
  }

  function feedProgressive(video, data, seekTo) {
    const src = data.progressive || data.video;
    if (!src || !src.url) return;
    cleanupMSE(video);
    video.__abmFeedGeneration = (video.__abmFeedGeneration || 0) + 1;
    const attempt = (video.__abmProgressiveAttempt || 0) + 1;
    video.__abmProgressiveAttempt = attempt;
    video.__abmProgressive = true; // progressive stream: browser can range-seek natively
    resetFrameWatch(video, false);
    try { video.removeAttribute("src"); video.load(); } catch (e) {}
    if (seekTo) {
      // Apply the seek on whichever readiness event lands first, and only if the playhead
      // isn't already there — guards the "loadedmetadata never fired, stuck at 0" freeze.
      const applySeek = () => { try { if (Math.abs((video.currentTime || 0) - seekTo) > 1) video.currentTime = seekTo; } catch (e) {} };
      video.addEventListener("loadedmetadata", applySeek, { once: true });
      video.addEventListener("canplay", applySeek, { once: true });
    }
    video.src = BASE + "/proxy?u=" + encodeURIComponent(src.url);
    log("progressive fallback", (src.height || "?") + "p", src === data.video ? "(video-only)" : "");
    setBadgeQuality(src.height);
    watchProgressiveStartup(video, cur && cur.id, attempt, seekTo);
    requestVideoPlay(video, "progressive play rejected");
  }

  // ---- quality + captions menu --------------------------------------------
  function escapeHtml(s) {
    return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
  }
  function closeMenu() { const p = document.getElementById("abm-reclaim-menu"); if (p) p.style.display = "none"; }
  function closeMenuGlobal() { const w = document.getElementById("abm-reclaim-menu-wrap"); if (w) w.remove(); }

  function markMenu(panel) {
    if (!cur) return;
    const rate = readRate();
    panel.querySelectorAll(".abm-mi").forEach((b) => {
      b.classList.remove("abm-active");
      if (b.dataset.q && parseInt(b.dataset.q, 10) === (cur.vFmt && cur.vFmt.height)) b.classList.add("abm-active");
      if (b.dataset.rate && parseFloat(b.dataset.rate) === rate) b.classList.add("abm-active");
      if (b.classList.contains("abm-cap")) {
        const idx = b.dataset.cap;
        if (idx === "" && !cur.capLang) b.classList.add("abm-active");
        else if (idx !== "") {
          const c = (cur.data.captions || [])[parseInt(idx, 10)];
          if (c && c.lang === cur.capLang) b.classList.add("abm-active");
        }
      }
    });
  }

  function showToast(text) {
    const stage = findStage(); if (!stage) return;
    let t = stage.querySelector("#abm-reclaim-toast");
    if (!t) { t = document.createElement("div"); t.id = "abm-reclaim-toast"; stage.appendChild(t); }
    t.textContent = text; t.style.opacity = "1";
    clearTimeout(t.__h); t.__h = setTimeout(() => { t.style.opacity = "0"; }, 1600);
  }

  async function switchQuality(height) {
    if (!cur) return;
    const v = (cur.data.videos || []).find((f) => f.height === height);
    if (!v || v === cur.vFmt) { closeMenu(); return; }
    const t = cur.video.currentTime || 0;
    cur.vFmt = v;
    cur.vIdx = null; cur.__idxPromise = null; // new quality → re-fetch its segment index
    closeMenu();
    showToast("Switching to " + height + "p…");
    await playSelection(t);
  }

  function setCaption(cap) {
    const video = cur && cur.video; if (!video) return;
    closeMenu();
    Array.from(video.querySelectorAll("track")).forEach((t) => t.remove());
    try { for (const tt of video.textTracks) tt.mode = "disabled"; } catch (e) {}
    cur.capLang = cap ? cap.lang : null;
    if (!cap) return;
    fetch(BASE + "/proxy?u=" + encodeURIComponent(cap.url))
      .then((r) => (r.ok ? r.text() : Promise.reject("HTTP " + r.status)))
      .then((vtt) => {
        if (!/^﻿?\s*WEBVTT/.test(vtt)) vtt = "WEBVTT\n\n" + vtt; // be forgiving of stray prefixes
        const track = document.createElement("track");
        track.kind = "subtitles"; track.label = cap.name; track.srclang = cap.lang;
        track.src = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
        track.default = true;
        video.appendChild(track);
        const show = () => { try { for (const tt of video.textTracks) if (tt.label === cap.name) tt.mode = "showing"; } catch (e) {} };
        track.addEventListener("load", show);
        setTimeout(show, 250);
      })
      .catch((e) => log("caption load failed", e));
  }

  function buildMenu(stage, data, video) {
    closeMenuGlobal();
    const wrap = document.createElement("div");
    wrap.id = "abm-reclaim-menu-wrap";
    const gear = document.createElement("button");
    gear.id = "abm-reclaim-gear"; gear.title = "Quality, speed & captions"; gear.textContent = "⚙";
    const panel = document.createElement("div");
    panel.id = "abm-reclaim-menu"; panel.style.display = "none";

    let html = '<div class="abm-mh">Quality</div>';
    for (const f of (data.videos || [])) {
      if (!f.height) continue;
      const tag = f.height >= 2160 ? " · 4K" : f.height >= 1440 ? " · QHD" : f.height >= 1080 ? " · HD" : "";
      html += '<button class="abm-mi" data-q="' + f.height + '">' + f.height + "p" + tag + "</button>";
    }
    html += '<div class="abm-mh">Speed</div>';
    for (const r of RATES) {
      html += '<button class="abm-mi" data-rate="' + r + '">' + (r === 1 ? "Normal" : r + "x") + "</button>";
    }
    if ((data.captions || []).length) {
      html += '<div class="abm-mh">Captions</div><button class="abm-mi abm-cap" data-cap="">Off</button>';
      (data.captions || []).forEach((c, i) => {
        html += '<button class="abm-mi abm-cap" data-cap="' + i + '">' + escapeHtml(c.name) + "</button>";
      });
    }
    panel.innerHTML = html;

    gear.addEventListener("click", (e) => {
      e.stopPropagation();
      const open = panel.style.display === "none";
      panel.style.display = open ? "block" : "none";
      if (open) markMenu(panel);
    });
    panel.addEventListener("click", (e) => {
      const b = e.target.closest("button"); if (!b) return;
      e.stopPropagation();
      if (b.dataset.rate) { setRate(parseFloat(b.dataset.rate)); }
      else if (b.dataset.q) switchQuality(parseInt(b.dataset.q, 10));
      else if (b.classList.contains("abm-cap")) {
        const idx = b.dataset.cap;
        setCaption(idx === "" ? null : (data.captions || [])[parseInt(idx, 10)]);
      }
    });
    document.addEventListener("click", closeMenu, true);

    wrap.appendChild(panel); wrap.appendChild(gear);
    stage.appendChild(wrap);
  }

  // ---- custom control bar --------------------------------------------------
  function fmtTime(s) {
    if (!isFinite(s) || s < 0) return "0:00";
    s = Math.floor(s);
    const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
    const mm = h ? String(m).padStart(2, "0") : String(m);
    return (h ? h + ":" : "") + mm + ":" + String(sec).padStart(2, "0");
  }
  // Full length for the seek bar: video.duration when known (MSE sets it from data.duration),
  // else the seekable end (live/HLS DVR window).
  function effDuration(video) {
    const d = video.duration;
    if (isFinite(d) && d > 0) return d;
    try { if (video.seekable.length) return video.seekable.end(video.seekable.length - 1); } catch (e) {}
    return 0;
  }

  function theaterHost(stage) {
    return (stage && stage.closest("ytd-watch-flexy")) || document.querySelector("ytd-watch-flexy");
  }

  function theaterActive(stage) {
    const host = theaterHost(stage);
    return !!(host && host.hasAttribute("theater")) || !!(stage && stage.classList.contains("ytp-wide-mode"));
  }

  function paintTheaterButton(stage, button) {
    const active = theaterActive(stage);
    const label = active ? "Exit theater mode (t)" : "Theater mode (t)";
    button.title = label;
    button.setAttribute("aria-label", label);
    button.setAttribute("aria-pressed", String(active));
  }

  function bindTheaterObserver(stage, paint) {
    const host = theaterHost(stage);
    if (!host || host.__abmTheaterObserver) return;
    host.__abmTheaterObserver = new MutationObserver(paint);
    host.__abmTheaterObserver.observe(host, { attributes: true, attributeFilter: ["theater"] });
  }

  function toggleTheaterMode(stage, button) {
    // Let YouTube own the layout transition whenever its control is present. This keeps
    // the surrounding page (recommendations, comments, and the player width) in sync.
    const native = stage.querySelector(".ytp-size-button") || document.querySelector(".ytp-size-button");
    if (native && typeof native.click === "function") native.click();
    else {
      // The fallback is mainly for stripped-down embeds/tests where YouTube's native
      // controls are absent but the watch layout host is still available.
      const host = theaterHost(stage);
      if (host) host.toggleAttribute("theater");
      else if (stage) stage.classList.toggle("ytp-wide-mode");
    }
    setTimeout(() => {
      paintTheaterButton(stage, button);
      showToast(theaterActive(stage) ? "Theater mode on" : "Theater mode off");
    }, 0);
  }

  let ctrlHideTimer = null;
  function buildControls(stage, video) {
    if (stage.querySelector("#abm-reclaim-controls")) return; // already built (reused across navs)
    video.controls = false; // we draw our own — native controls won't show a usable seek bar over MSE
    const bar = document.createElement("div");
    bar.id = "abm-reclaim-controls";
    bar.innerHTML =
      '<button class="abm-rc-pp" title="Play / pause (k)">▶</button>' +
      '<span class="abm-rc-time abm-rc-cur">0:00</span>' +
      '<div id="abm-rc-seek"><div class="abm-rc-track"></div><div class="abm-rc-buf"></div>' +
      '<div class="abm-rc-play"></div><div class="abm-rc-thumb"></div></div>' +
      '<span class="abm-rc-time abm-rc-dur">0:00</span>' +
      '<button class="abm-rc-mute" title="Mute (m)">🔊</button>' +
      '<input id="abm-rc-vol" type="range" min="0" max="1" step="0.05" value="1">' +
      '<button class="abm-rc-theater" type="button" title="Theater mode (t)" aria-label="Theater mode (t)" aria-pressed="false">▭</button>' +
      '<button class="abm-rc-fs" title="Fullscreen (f)">⛶</button>';
    stage.appendChild(bar);

    const pp = bar.querySelector(".abm-rc-pp");
    const curT = bar.querySelector(".abm-rc-cur");
    const durT = bar.querySelector(".abm-rc-dur");
    const seek = bar.querySelector("#abm-rc-seek");
    const buf = bar.querySelector(".abm-rc-buf");
    const playFill = bar.querySelector(".abm-rc-play");
    const thumb = bar.querySelector(".abm-rc-thumb");
    const muteBtn = bar.querySelector(".abm-rc-mute");
    const vol = bar.querySelector("#abm-rc-vol");
    const theaterBtn = bar.querySelector(".abm-rc-theater");
    const fsBtn = bar.querySelector(".abm-rc-fs");

    bindTheaterObserver(stage, () => paintTheaterButton(stage, theaterBtn));
    paintTheaterButton(stage, theaterBtn);

    pp.addEventListener("click", () => {
      if (video.paused) requestVideoPlay(video, "play button rejected");
      else video.pause();
    });
    video.addEventListener("play", () => { pp.textContent = "❚❚"; });
    video.addEventListener("pause", () => { pp.textContent = "▶"; });
    pp.textContent = video.paused ? "▶" : "❚❚";

    function paint() {
      const d = effDuration(video);
      const t = video.currentTime || 0;
      const live = !(isFinite(video.duration) && video.duration > 0);
      const pct = d > 0 ? Math.min(100, (t / d) * 100) : 0;
      playFill.style.width = pct + "%";
      thumb.style.left = pct + "%";
      try {
        const b = video.buffered;
        if (b && b.length) buf.style.width = (d > 0 ? Math.min(100, (b.end(b.length - 1) / d) * 100) : 0) + "%";
      } catch (e) {}
      curT.textContent = fmtTime(t);
      durT.textContent = live ? "LIVE" : fmtTime(d);
    }
    video.addEventListener("timeupdate", paint);
    video.addEventListener("progress", paint);
    video.addEventListener("durationchange", paint);
    paint();

    // Seek: clicking/dragging the bar sets currentTime, which fires the existing onSeek
    // handler (HD in-stream index seek, or progressive fallback) for jumps past the buffer.
    function ratioAt(e) {
      const r = seek.getBoundingClientRect();
      return r.width ? Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) : 0;
    }
    function doSeek(e) {
      const d = effDuration(video);
      if (d <= 0) return;
      try { video.currentTime = ratioAt(e) * d; } catch (err) {}
      paint();
    }
    seek.addEventListener("mousedown", (e) => {
      if (e.button !== 0) return;
      e.preventDefault();
      seek.classList.add("abm-drag");
      doSeek(e);
      const mv = (ev) => doSeek(ev);
      const up = () => {
        seek.classList.remove("abm-drag");
        document.removeEventListener("mousemove", mv, true);
        document.removeEventListener("mouseup", up, true);
      };
      document.addEventListener("mousemove", mv, true);
      document.addEventListener("mouseup", up, true);
    });

    function paintVol() {
      muteBtn.textContent = (video.muted || video.volume === 0) ? "🔇" : "🔊";
      vol.value = String(video.muted ? 0 : video.volume);
    }
    muteBtn.addEventListener("click", () => { video.muted = !video.muted; });
    vol.addEventListener("input", () => { video.muted = false; video.volume = parseFloat(vol.value); });
    video.addEventListener("volumechange", paintVol);
    paintVol();

    theaterBtn.addEventListener("click", (e) => {
      e.stopPropagation();
      toggleTheaterMode(stage, theaterBtn);
    });

    fsBtn.addEventListener("click", () => {
      try {
        if (document.fullscreenElement) document.exitFullscreen();
        else (stage.requestFullscreen ? stage : video).requestFullscreen();
      } catch (e) {}
    });

    // Auto-hide while playing; reveal on pointer movement over the player. Resolve the live
    // player by id (not the captured `video`) so this stays correct across SPA navigations,
    // where #movie_player persists but the <video> element is replaced.
    const liveVid = () => document.getElementById(PLAYER_ID);
    function showCtrl() {
      stage.classList.remove("abm-hide-ctrl");
      if (ctrlHideTimer) clearTimeout(ctrlHideTimer);
      ctrlHideTimer = setTimeout(() => { const cv = liveVid(); if (cv && !cv.paused) stage.classList.add("abm-hide-ctrl"); }, 2800);
    }
    if (!stage.__abmCtrlBound) {
      stage.__abmCtrlBound = true;
      stage.addEventListener("mousemove", showCtrl);
      stage.addEventListener("mouseleave", () => { const cv = liveVid(); if (cv && !cv.paused) stage.classList.add("abm-hide-ctrl"); });
    }
    showCtrl();
  }

  function teardown() {
    window.__abmReclaimActive = false;
    const ctrls = document.getElementById("abm-reclaim-controls");
    if (ctrls) ctrls.remove();
    if (ctrlHideTimer) { clearTimeout(ctrlHideTimer); ctrlHideTimer = null; }
    document.querySelectorAll(".abm-hide-ctrl").forEach((s) => s.classList.remove("abm-hide-ctrl"));
    const theater = document.querySelector("ytd-watch-flexy");
    if (theater && theater.__abmTheaterObserver) {
      theater.__abmTheaterObserver.disconnect();
      theater.__abmTheaterObserver = null;
    }
    const v = document.getElementById(PLAYER_ID);
    if (v) {
      if (v.__abmStallIv) { clearInterval(v.__abmStallIv); v.__abmStallIv = null; }
      if (v.__abmFrameWatch && v.__abmFrameWatch.callbackId !== null &&
          typeof v.cancelVideoFrameCallback === "function") {
        try { v.cancelVideoFrameCallback(v.__abmFrameWatch.callbackId); } catch (e) {}
      }
      v.__abmFrameWatch = null;
      try { cleanupMSE(v); v.pause(); v.removeAttribute("src"); v.load(); } catch (e) {}
      v.remove();
    }
    document.querySelectorAll(".abm-reclaimed").forEach((s) => s.classList.remove("abm-reclaimed"));
    const badge = document.getElementById("abm-reclaim-badge");
    if (badge) badge.remove();
    closeMenuGlobal();
    const toast = document.getElementById("abm-reclaim-toast");
    if (toast) toast.remove();
    const stage = findStage();
    if (stage) setMessage(stage, "");
    unsuppressNative();
    cur = null;
    mountedId = null;
  }

  // ---- orchestration ------------------------------------------------------
  async function sync() {
    if (!enabled) { teardown(); return; }
    const id = watchVideoId();
    if (!id) { if (mountedId) teardown(); return; }     // not a watch page
    if (id === mountedId) return;                        // already on it
    if (!shouldEngage()) { if (mountedId) teardown(); return; } // dormant — native player runs (instant seek)
    const p = ensurePrefetch(id);                        // fire extraction ASAP (parallel w/ page)
    if (resolving) return;
    log("engaging reclaim", id, forceAlways ? "(always)" : "(enforced)");
    resolving = true;
    try { await mount(id, p); }
    finally { resolving = false; }
  }

  function boot() {
    // Earliest possible kick: at document_start the watch URL is already known on a fresh load,
    // so extraction starts before YouTube has even built its player.
    const id0 = watchVideoId();
    if (id0 && shouldEngage()) ensurePrefetch(id0);
    sync();
    // YouTube is a SPA — react to its soft navigations + as a fallback poll the URL.
    const onNav = () => { const id = watchVideoId(); if (id && shouldEngage()) ensurePrefetch(id); setTimeout(sync, 30); };
    window.addEventListener("yt-navigate-finish", onNav, true);
    document.addEventListener("yt-navigate-finish", onNav, true);
    window.addEventListener("yt-navigate-start", onNav, true);
    let lastHref = location.href;
    setInterval(() => {
      if (location.href !== lastHref) { lastHref = location.href; onNav(); }
    }, 500);
  }
})();
