☰

Jable-Tools

Like/view ratio badges (0–200 score, rarity tiers) + read-ahead pagination on jable.tv video cards. ⚙ settings apply live.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Jable-Tools
// @namespace    https://greasyfork.org/scripts/599011
// @version      0.4.1
// @description  Like/view ratio badges (0–200 score, rarity tiers) + read-ahead pagination on jable.tv video cards. ⚙ settings apply live.
// @author       vedesh137
// @match        https://jable.tv/*
// @match        https://www.jable.tv/*
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-idle
// @noframes
// @license      MIT
// ==/UserScript==


/* ================= defaults: ratio-config.js (shared) ================= */

/**
 * Jable-Tools — defaults for the like/view score and rarity tiers.
 *
 * score = (likes / views) × scoreScale, capped at scoreMax
 * (default: 1% like rate → 100 points, 2% → the 200 cap, 0 likes → 0).
 *
 * Calibrated on ~240 cards sampled from 10 pages of /tags/fishnets/
 * (2025-07): median like rate ≈ 0.77% (score ≈ 77), p90 ≈ 1.27%
 * (score ≈ 127), max ≈ 2.03% (hits the cap).
 *
 * Tier order = precedence: a card belongs to the first tier whose `max`
 * exceeds its score; the last tier catches everything else (use
 * `max: Infinity` or leave it empty in the popup).
 *
 * These are the factory defaults — the user changes everything in the
 * extension popup (persisted to chrome.storage.local).
 */
window.JT_DEFAULTS = {
  enabled: true,
  scoreScale: 10000,
  scoreMax: 200,
  // Listing pages fetched and appended beyond the current one (0 = off).
  pagesAhead: 0,
  tiers: [
    // common     0  – 49.9   (≈25% of the sample)
    { name: "common", color: "#9ca3af", max: 50, visible: true },
    // uncommon  50 – 99.9   (≈42% of the sample)
    { name: "uncommon", color: "#22c55e", max: 100, visible: true },
    // rare     100 – 139.9  (≈25% of the sample)
    { name: "rare", color: "#3b82f6", max: 140, visible: true },
    // epic     140 – 174.9  (≈4% of the sample)
    { name: "epic", color: "#a855f7", max: 175, visible: true },
    // legendary 175 – 200   (≈4% of the sample)
    { name: "legendary", color: "#f59e0b", max: Infinity, visible: true },
  ],
};


/* ================= shared core: common.js (shared) ================= */

/**
 * Jable-Tools — shared helpers, used by the content script and the popup.
 * Load after ratio-config.js (needs window.JT_DEFAULTS).
 */
(() => {
  "use strict";

  const D = window.JT_DEFAULTS;

  /** Max "read ahead pages" accepted from the settings UI. */
  const AHEAD_MAX = 9;

  /**
   * Normalizes a stored config (or null) into a safe shape:
   *   { enabled, tiers: [{ name, color, max, visible }], pagesAhead }
   * `max` null / empty / NaN → Infinity (catch-all tier).
   */
  function normalize(raw) {
    const tiers =
      raw && Array.isArray(raw.tiers) && raw.tiers.length
        ? raw.tiers.map((t) => {
            const n = Number(t.max);
            return {
              name: t.name != null ? String(t.name) : "tier",
              color: t.color != null ? String(t.color) : "#9ca3af",
              max:
                t.max === null || t.max === undefined || t.max === "" || !Number.isFinite(n)
                  ? Infinity
                  : n,
              visible: t.visible !== false,
            };
          })
        : D.tiers.map((t) => ({ ...t }));
    const pagesAhead = raw ? Number(raw.pagesAhead) : NaN;
    return {
      enabled: raw ? raw.enabled !== false : D.enabled,
      tiers,
      pagesAhead: Number.isFinite(pagesAhead)
        ? Math.min(AHEAD_MAX, Math.max(0, Math.floor(pagesAhead)))
        : D.pagesAhead,
    };
  }

  /** score(views, likes) on the 0..scoreMax scale. */
  function score(views, likes) {
    if (views > 0) return Math.min(D.scoreMax, (likes / views) * D.scoreScale);
    return likes > 0 ? D.scoreMax : 0;
  }

  /** First tier whose max exceeds the score; last tier catches the rest. */
  function tierFor(score, tiers) {
    for (const tier of tiers) if (score < tier.max) return tier;
    return tiers[tiers.length - 1] || { name: "none", color: "#9ca3af", visible: true };
  }

  function formatScore(score) {
    return score.toFixed(1);
  }

  /**
   * Builds the settings tier rows into `container` (shared by the extension
   * popup and the userscript floating panel). `persist` is called after any
   * value that must be saved (name changes included).
   */
  function mountTierRows(container, config, persist) {
    container.textContent = "";
    config.tiers.forEach((tier) => {
      const row = document.createElement("label");
      row.className = "tier-row";

      const visible = document.createElement("input");
      visible.type = "checkbox";
      visible.checked = tier.visible;
      visible.title = "Show videos of this tier";
      visible.addEventListener("change", () => {
        tier.visible = visible.checked;
        persist();
      });

      const name = document.createElement("input");
      name.type = "text";
      name.value = tier.name;
      name.title = "Tier name";
      name.addEventListener("change", () => {
        tier.name = name.value || "tier";
        persist();
      });

      const color = document.createElement("input");
      color.type = "color";
      color.value = tier.color;
      color.title = "Badge color";
      color.addEventListener("input", () => {
        tier.color = color.value;
        persist();
      });

      const max = document.createElement("input");
      max.type = "number";
      max.min = "0";
      max.step = "5";
      max.placeholder = "∞";
      max.value = Number.isFinite(tier.max) ? String(tier.max) : "";
      max.title = "This tier applies to scores below this value";
      max.addEventListener("change", () => {
        const n = Number(max.value);
        tier.max = max.value === "" || !Number.isFinite(n) ? Infinity : n;
        persist();
      });

      row.append(visible, name, color, max);
      container.appendChild(row);
    });
  }

  /** Converts an in-memory config into something storage can persist
   * (JSON has no Infinity). */
  function forStorage(config) {
    return {
      enabled: config.enabled,
      pagesAhead: config.pagesAhead,
      tiers: config.tiers.map((t) => ({
        ...t,
        max: Number.isFinite(t.max) ? t.max : null,
      })),
    };
  }

  window.JT = {
    STORE_KEY: "jableTools",
    normalize,
    score,
    tierFor,
    formatScore,
    forStorage,
    mountTierRows,
  };
})();


/* ================= host adapter: userscript/src/host.js ================= */

/**
 * Jable-Tools — storage host for the userscript.
 *
 * Backs `window.JT_HOST` (same API as the extension's host-extension.js)
 * with Tampermonkey's GM values when available, localStorage otherwise.
 * A light 2s poll keeps other tabs/pages in sync — userscripts have no
 * cross-page storage event.
 */
(() => {
  "use strict";

  const KEY = window.JT.STORE_KEY;
  const useGM = typeof GM_setValue === "function" && typeof GM_getValue === "function";

  const listeners = new Set();
  let lastRawText = null;

  function readText() {
    let text = null;
    if (useGM) {
      text = GM_getValue(KEY, null);
    } else {
      try {
        text = localStorage.getItem(KEY);
      } catch {
        text = null;
      }
    }
    return text;
  }

  function readRaw() {
    const text = readText();
    if (text == null) return null;
    try {
      return JSON.parse(text);
    } catch {
      return null;
    }
  }

  window.JT_HOST = {
    async load() {
      return readRaw();
    },
    save(raw) {
      const text = JSON.stringify(raw);
      if (useGM) GM_setValue(KEY, text);
      else {
        try {
          localStorage.setItem(KEY, text);
        } catch {
          /* storage full / blocked — in-memory only */
        }
      }
      lastRawText = text;
      emit(raw);
    },
    onChange(fn) {
      listeners.add(fn);
    },
  };

  function emit(raw) {
    for (const fn of listeners) fn(raw);
  }

  // Cross-tab sync: pick up config written by another tab/page.
  setInterval(() => {
    const text = readText();
    if (text === lastRawText) return;
    lastRawText = text;
    let raw = null;
    if (text != null) {
      try {
        raw = JSON.parse(text);
      } catch {
        raw = null;
      }
    }
    if (raw !== null) emit(raw);
  }, 2000);
})();


/* ================= content core: content.js (shared) ================= */

/**
 * Jable-Tools content script.
 *
 * On every jable.tv browse page (home, latest updates, hot, search,
 * categories, tags) each video card carries its stats in
 * `<p class="sub-title">`:
 *
 *   <svg><use xlink:href="#icon-eye"></use></svg>28 330
 *   <svg><use xlink:href="#icon-heart-inline"></use></svg>210
 *
 * (numbers are space-grouped, e.g. "28 330" = 28330)
 *
 * For every card we:
 *   1. parse views and likes,
 *   2. compute the normalized score = (likes / views) × scale, capped
 *      (0–200; see ratio-config.js),
 *   3. pick the rarity tier from the live config,
 *   4. log it to the DevTools console (once per video),
 *   5. append a colored badge (icon + score) to the card,
 *   6. hide the card if the tier is switched off in the popup.
 *
 * Read ahead: with "read ahead pages" > 0 in the settings, the next N
 * listing pages are fetched and their cards appended to the grid, so a
 * filtered page (e.g. only epic + legendary) still shows the matches from
 * the pages ahead.
 *
 * The config is persisted through `window.JT_HOST` (chrome.storage.local in
 * the extension, GM values / localStorage in the userscript), edited from
 * the extension popup or the userscript's floating ⚙ panel; the host's
 * `onChange` re-applies it live, and a MutationObserver picks up cards
 * added later (pagination, search, re-renders).
 */
(() => {
  "use strict";

  if (window.__jtBootstrapped) return;
  window.__jtBootstrapped = true;

  /** subTitle → { views, likes, badge, cardRoot } (pruned on each pass) */
  const cards = new Map();
  const loggedKeys = new Set();
  let config = window.JT.normalize(null);
  let scanQueued = false;

  /** Read-ahead pages, in order: { url, cells, nextUrl }. */
  const ahead = [];
  let aheadToken = 0;
  const AHEAD_MAX = 9;

  /** "28 330" / "28,330" / "28330" → 28330; null when not a number. */
  function parseCount(text) {
    const cleaned = (text || "").replace(/[\s,]/g, "");
    if (!cleaned) return null;
    const value = Number(cleaned);
    return Number.isFinite(value) ? value : null;
  }

  /** Reads views/likes from a card's sub-title. Missing parts are null. */
  function parseStats(subTitle) {
    let metric = null;
    let views = null;
    let likes = null;
    for (const node of subTitle.childNodes) {
      if (node.nodeType === Node.TEXT_NODE) {
        const value = parseCount(node.textContent);
        if (value === null || metric === null) continue;
        if (metric === "views") views = views === null ? value : views + value;
        else likes = likes === null ? value : likes + value;
        continue;
      }
      if (node.nodeType !== Node.ELEMENT_NODE) continue;
      const use = node.querySelector("use");
      const href = use
        ? use.getAttribute("xlink:href") || use.getAttribute("href") || ""
        : "";
      if (href.includes("icon-eye")) metric = "views";
      else if (href.includes("heart")) metric = "likes";
      else metric = null;
    }
    return { views, likes };
  }

  /**
   * The element to hide/show: the grid cell the card sits in, so the
   * flex row reflows and the remaining cards flow sequentially.
   * Falls back through carousel wrappers to the card itself.
   */
  function cardRoot(subTitle) {
    const card = subTitle.closest(".video-img-box");
    if (!card) return null;
    return (
      card.closest(".owl-item") ||
      card.closest('[class*="col-"]') ||
      card.closest(".item") ||
      card
    );
  }

  function cardTitle(subTitle) {
    const detail = subTitle.closest(".detail");
    const title = detail ? detail.querySelector(".title") : null;
    return title && title.textContent.trim() ? title.textContent.trim() : "unknown video";
  }

  /** Grid row holding the video cells; null on non-listing pages. */
  function liveRow() {
    const subTitle = document.querySelector("p.sub-title");
    const cell = subTitle ? subTitle.closest('[class*="col-"]') : null;
    return cell ? cell.parentElement : null;
  }

  /**
   * Page number of a pagination element (`a.page-link` / the active span),
   * null when it can't be told. Prefers the `data-jt-page` marker set by
   * `syncPagination()` (visible labels are screen labels, not pages);
   * an anchor falls back to its href (trailing path number, `?page=`,
   * bare listing URL = 1); the active span (no href) to its number text.
   */
  function pageOf(el) {
    if (!el) return null;
    if (el.dataset && el.dataset.jtPage) return Number(el.dataset.jtPage);
    if (el.tagName === "A") {
      const href = el.getAttribute("href");
      if (href) {
        try {
          const u = new URL(href, location.origin);
          const segs = u.pathname.split("/").filter(Boolean);
          const seg = segs[segs.length - 1];
          if (/^\d+$/.test(seg)) return Number(seg);
          const q = Number.parseInt(u.searchParams.get("page") || "", 10);
          if (Number.isFinite(q) && q > 0) return q;
          return 1; // bare listing URL = page 1
        } catch {
          return null;
        }
      }
    }
    const n = Number.parseInt((el.textContent || "").trim(), 10);
    return Number.isFinite(n) ? n : null;
  }

  /**
   * URL of the page after `scope`'s current one. Uses the learned URL
   * pattern (`learnPagination`) when available — that also covers the
   * reshaped pagination, whose buttons are no longer plain page links;
   * otherwise falls back to reading the site's stock links' hrefs.
   * Null when the pagination is missing or this is the last page.
   */
  function nextPageUrl(scope) {
    const pagination = scope ? scope.querySelector("ul.pagination") : null;
    if (!pagination) return null;
    const current = pageOf(pagination.querySelector(".page-link.active"));
    if (current === null) return null;
    if (pgPattern && pgPattern.total) {
      if (current >= pgPattern.total) return null;
      return urlForPage(current + 1);
    }
    const links = [...pagination.querySelectorAll("a.page-link")];
    const next = links.find((l) => pageOf(l) === current + 1);
    const nextHref = next ? next.getAttribute("href") : null;
    if (nextHref) return new URL(nextHref, location.origin).href;
    // current+1 is outside the shown window (active is the last shown page
    // but not the site's last page): build it by swapping the page number
    // in the "last" link's URL.
    const last = links[links.length - 1];
    const lastHref = last ? last.getAttribute("href") : null;
    const lastNum = last ? pageOf(last) : null;
    if (lastHref && lastNum !== null && lastNum > current) {
      const qIdx = lastHref.indexOf("?");
      const pathPart = qIdx === -1 ? lastHref : lastHref.slice(0, qIdx);
      const queryPart = qIdx === -1 ? "" : lastHref.slice(qIdx);
      const nextPath = pathPart.replace(
        /(\d+)(\/?)$/, String(current + 1) + (pathPart.endsWith("/") ? "/" : "")
      );
      return new URL(nextPath + queryPart, location.origin).href;
    }
    return null;
  }

  /**
   * The `sort_by` value the site's pagination is currently using (the
   * stock links' data-parameters always reflect the active sort — the site
   * rewrites them when the user changes it), null when it can't be told.
   */
  function sortOf(pagination) {
    if (!pagination) return null;
    for (const a of pagination.querySelectorAll("a.page-link")) {
      if (a.dataset.jtOurs) continue;
      const m = /sort_by:([\w]+)/.exec(a.getAttribute("data-parameters") || "");
      if (m) return m[1];
    }
    return null;
  }

  // URL pattern of the listing's pagination, learned from the site's own
  // (stock) links before they are replaced: { firstUrl (page 1 href),
  // total (last page), prefix/tail (page N = prefix + N + tail), template
  // (a stock numeric <a>, so injected links keep the site's attributes —
  // e.g. data-action="ajax" — and trigger the site's own handler) }.
  let pgPattern = null;

  function learnPagination(pagination) {
    let firstUrl = null;
    let total = null;
    let prefix = null;
    let tail = null;
    let template = null;
    let templateParams = null;
    let sort = null;
    for (const a of pagination.querySelectorAll("a.page-link")) {
      if (a.dataset.jtOurs) continue; // our own injected links
      const p = pageOf(a);
      if (p === null) continue;
      const href = a.getAttribute("href") || "";
      if (p === 1 && !firstUrl) firstUrl = href;
      if (total === null || p > total) total = p;
      if (!template && /^\d+$/.test((a.textContent || "").trim())) {
        template = a;
        templateParams = a.getAttribute("data-parameters");
      }
      if (sort === null) {
        const ms = /sort_by:([\w]+)/.exec(a.getAttribute("data-parameters") || "");
        if (ms) sort = ms[1];
      }
      if (p >= 2 && prefix === null) {
        const m = /^(.*\/)\d+(\/?)$/.exec(href);
        if (m) {
          prefix = m[1];
          tail = m[2] || "";
        }
      }
    }
    if (firstUrl === null) {
      // Page 1 renders no page-1 <a> (the active span stands in for it),
      // so synthesize: the prefix is the canonical page-1 URL; when the
      // pattern is unknown, a URL with no page number in its path is
      // page 1 itself.
      if (prefix) firstUrl = prefix;
      else {
        const path = location.href.split(/[?#]/)[0];
        const segs = path.split("/").filter(Boolean);
        if (!/^\d+$/.test(segs[segs.length - 1] || "")) firstUrl = path;
      }
    }
    if (firstUrl === null) return;
    pgPattern = {
      firstUrl,
      total: total ?? (pgPattern ? pgPattern.total : null),
      prefix: prefix ?? (pgPattern ? pgPattern.prefix : null),
      tail: tail ?? (pgPattern ? pgPattern.tail : "/"),
      template: template
        ? template.cloneNode(false)
        : pgPattern
          ? pgPattern.template
          : null,
      templateParams:
        templateParams ?? (pgPattern ? pgPattern.templateParams : null),
      sort: sort ?? (pgPattern ? pgPattern.sort : null),
    };
  }

  /**
   * URL of listing page p per the learned pattern (root-relative), with the
   * listing's current sort appended: the site's sort (Recent Update / Most
   * Viewed / Most Favourited / Best Recently) is AJAX-only and never part
   * of the URL, so without this every fetch of page N > 1 would silently
   * fall back to the server's default sort (Recent Update).
   */
  function urlForPage(p) {
    if (!pgPattern || !pgPattern.prefix) return null;
    const url =
      p <= 1
        ? pgPattern.firstUrl
        : pgPattern.prefix + String(p) + (pgPattern.tail || "");
    if (!url || !pgPattern.sort) return url;
    return url + (url.includes("?") ? "&" : "?") + "sort_by=" + pgPattern.sort;
  }

  /**
   * Reshape the site's pagination around the read-ahead window: with N
   * ahead pages, one page on screen + N fetched means each button should
   * be a "screen" of (1+N) pages. The site's per-page buttons for
   * in-between pages are removed and replaced by one button per screen,
   * labelled with that screen's LAST page — so the highlighted number is
   * always the last page currently on screen and clicking "K" shows up to
   * page K (no mental math). Buttons navigate the site to the screen's
   * FIRST page through its normal (AJAX) href, so the site's own handler
   * does the re-render; "« First" / "Last »" are left alone. N=0 degrades
   * to exactly the site's stock numbering (one page per button).
   * Re-applying is a no-op (labels+hrefs compared before touching the DOM).
   */
  function syncPagination() {
    const n =
      config && config.enabled
        ? Math.min(AHEAD_MAX, Math.max(0, config.pagesAhead | 0))
        : 0;
    const pagination = document.querySelector("ul.pagination");
    if (!pagination) return;
    const span = pagination.querySelector(".page-link.active");
    if (!span) return;
    const current = lastActivePage;
    if (current === null || current === undefined) return;
    learnPagination(pagination);
    const total = pgPattern ? pgPattern.total : null;
    if (!total || total <= 1) return;
    const size = n + 1;
    const screens = Math.ceil(total / size);
    const here = Math.min(screens, Math.ceil(current / size));
    const spanLabel = String(Math.min(current + n, total)).padStart(2, "0");
    if (span.textContent !== spanLabel) span.textContent = spanLabel;
    spanLabelSet = spanLabel;
    span.dataset.jtPage = String(current);
    span.dataset.jtOurs = "1";
    const want = [];
    for (let s = Math.max(1, here - 2); s <= Math.min(screens, here + 2); s++) {
      if (s === here) continue; // the active span stands in for it
      const first = (s - 1) * size + 1;
      want.push({
        s,
        label: String(Math.min(s * size, total)).padStart(2, "0"),
        href: urlForPage(first),
      });
    }
    const desired = want.map((w) => w.label + "@" + w.href);
    const have = [...pagination.querySelectorAll("li[data-jt-li] a.page-link")].map(
      (a) => (a.textContent || "").trim() + "@" + (a.getAttribute("href") || "")
    );
    if (have.join("|") === desired.join("|")) return; // already in sync
    for (const li of [...pagination.querySelectorAll("li")]) {
      const a = li.querySelector("a.page-link");
      if (li.dataset.jtLi || (a && /^\d+$/.test((a.textContent || "").trim())))
        li.remove();
    }
    const spanLi = span.closest("li");
    const make = (w) => {
      const li = document.createElement("li");
      li.dataset.jtLi = "1";
      const a = pgPattern && pgPattern.template
        ? pgPattern.template.cloneNode(false)
        : (() => {
            const x = document.createElement("a");
            x.className = "page-link";
            return x;
          })();
      const first = (w.s - 1) * size + 1;
      a.setAttribute("href", w.href);
      a.setAttribute("aria-label", "Page " + w.label);
      // The site's AJAX handler reads the page from data-parameters
      // ("...;from:NN"), not from the href — refresh it per button.
      if (pgPattern && pgPattern.templateParams !== null) {
        a.setAttribute(
          "data-parameters",
          pgPattern.templateParams.replace(
            /from:\d+/i,
            "from:" + String(first).padStart(2, "0")
          )
        );
      }
      a.dataset.jtPage = String(first);
      a.dataset.jtOurs = "1";
      a.textContent = w.label;
      li.appendChild(a);
      return li;
    };
    for (const w of want) {
      if (!w.href || w.s >= here) continue;
      spanLi.insertAdjacentElement("beforebegin", make(w));
    }
    for (const w of [...want].reverse()) {
      if (!w.href || w.s <= here) continue;
      spanLi.insertAdjacentElement("afterend", make(w));
    }
  }

  /**
   * Fetch one listing page. Returns { cells (adopted into this document,
   * deduped by video href against `knownHrefs`), nextUrl } or null when
   * the page cannot be fetched or has no video grid.
   */
  async function fetchListingPage(url, knownHrefs) {
    // The listing changes (uploads, views): never trust a cached copy.
    const busted = url + (url.includes("?") ? "&" : "?") + "_=" + Date.now();
    const response = await fetch(busted, { credentials: "same-origin" });
    if (!response.ok) return null;
    const doc = new DOMParser().parseFromString(await response.text(), "text/html");
    const nextUrl = nextPageUrl(doc);
    const firstCell = doc.querySelector("p.sub-title");
    const row = firstCell ? firstCell.closest('[class*="col-"]')?.parentElement : null;
    if (!row) return null;
    const cells = [];
    // Snapshot first: adoptNode() below removes each cell from this live
    // collection, which would otherwise shift children up and make the loop
    // skip every other cell (half of each page's cards would be dropped).
    const cellList = [...row.children].filter((child) =>
      String(child.className || "").includes("col-")
    );
    for (const cell of cellList) {
      const title = cell.querySelector(".detail .title a");
      const href = title ? title.href : null;
      if (href) {
        if (knownHrefs.has(href)) continue;
        knownHrefs.add(href);
      }
      cells.push(document.adoptNode(cell));
    }
    return { cells, nextUrl };
  }

  /**
   * Keep the number of appended pages in line with config.pagesAhead:
   * trim from the end when it shrank (or the tool is disabled), fetch +
   * append when it grew. Appended cells flow through the normal scan
   * (badges, tier hiding, console log) exactly like native cards.
   */
  async function syncReadAhead() {
    const token = ++aheadToken;
    try {
      const target =
        config && config.enabled
          ? Math.min(AHEAD_MAX, Math.max(0, config.pagesAhead | 0))
          : 0;

      while (ahead.length > target) {
        const page = ahead.pop();
        for (const cell of page.cells) cell.remove();
      }
      // The site re-renders the whole grid (sort change, AJAX pagination):
      // our appended cells may be gone from the DOM while the bookkeeping
      // above still lists them — drop them and re-fetch below.
      for (let i = ahead.length - 1; i >= 0; i--) {
        const cells = ahead[i].cells;
        if (!cells.length || !cells[cells.length - 1].isConnected) {
          for (const cell of cells) cell.remove();
          ahead.splice(0, i + 1);
          break;
        }
      }
      if (!config || !config.enabled || ahead.length >= target || !liveRow()) return;

      const knownHrefs = new Set();
      for (const subTitle of document.querySelectorAll("p.sub-title")) {
        const detail = subTitle.closest(".detail");
        const title = detail ? detail.querySelector(".title a") : null;
        if (title && title.href) knownHrefs.add(title.href);
      }

      let guard = 0;
      while (ahead.length < target && guard++ <= AHEAD_MAX) {
        const url = ahead.length ? ahead[ahead.length - 1].nextUrl : nextPageUrl(document);
        if (!url) break;
        let page = null;
        try {
          page = await fetchListingPage(url, knownHrefs);
        } catch {
          page = null;
        }
        if (token !== aheadToken) return; // a newer sync took over
        if (!page || !page.cells.length) break;
        const row = liveRow();
        if (!row) break;
        row.append(...page.cells);
        ahead.push({ url, cells: page.cells, nextUrl: page.nextUrl });
      }
    } catch (err) {
      console.warn("[Jable-Tools] read-ahead failed:", err);
    }
  }

  function createBadge() {
    const SVG_NS = "http://www.w3.org/2000/svg";
    const badge = document.createElement("span");
    badge.className = "jt-ratio";

    // Small ascending-bars icon; color is set per tier on every update.
    const icon = document.createElementNS(SVG_NS, "svg");
    icon.setAttribute("class", "jt-ratio-icon");
    icon.setAttribute("width", "10");
    icon.setAttribute("height", "10");
    icon.setAttribute("viewBox", "0 0 10 10");
    icon.setAttribute("aria-hidden", "true");
    for (const [x, y, h] of [[0, 6, 4], [4, 3, 7], [8, 0, 10]]) {
      const bar = document.createElementNS(SVG_NS, "rect");
      bar.setAttribute("x", String(x));
      bar.setAttribute("y", String(y));
      bar.setAttribute("width", "2");
      bar.setAttribute("height", String(h));
      icon.appendChild(bar);
    }
    badge.appendChild(icon);

    const num = document.createElement("span");
    num.className = "jt-ratio-num";
    badge.appendChild(num);
    return badge;
  }

  function collect() {
    for (const subTitle of document.querySelectorAll("p.sub-title")) {
      if (cards.has(subTitle)) continue;
      const { views, likes } = parseStats(subTitle);
      if (views === null || likes === null) continue;
      cards.set(subTitle, { views, likes, badge: null, cardRoot: cardRoot(subTitle) });
    }
  }

  function update(subTitle, rec) {
    const { views, likes } = rec;
    const score = window.JT.score(views, likes);
    const tier = window.JT.tierFor(score, config.tiers);
    const scoreText = window.JT.formatScore(score);
    const title = `${views.toLocaleString("en-US")} views / ${likes.toLocaleString("en-US")} likes`;

    if (config.enabled) {
      if (!rec.badge) {
        rec.badge = createBadge();
        subTitle.appendChild(rec.badge);
      }
      const icon = rec.badge.querySelector(".jt-ratio-icon");
      icon.style.color = tier.color;
      rec.badge.querySelector(".jt-ratio-num").textContent = scoreText;
      rec.badge.title = `Jable-Tools — ${title} → score ${scoreText} (${tier.name})`;
    } else if (rec.badge) {
      rec.badge.remove();
      rec.badge = null;
    }

    if (rec.cardRoot) {
      rec.cardRoot.style.display = config.enabled && !tier.visible ? "none" : "";
    }

    if (config.enabled) {
      const key = `${cardTitle(subTitle)}|${views}|${likes}`;
      if (!loggedKeys.has(key)) {
        loggedKeys.add(key);
        const likeRate = views > 0 ? `likes/views: ${((likes / views) * 100).toFixed(2)}%, ` : "";
        console.info(
          `[Jable-Tools] ${cardTitle(subTitle)} — views: ${views.toLocaleString("en-US")}, ` +
            `likes: ${likes.toLocaleString("en-US")}, ${likeRate}score: ${scoreText} (${tier.name})`
        );
      }
    }
  }

  function applyAll() {
    for (const [subTitle] of cards) {
      if (!subTitle.isConnected) cards.delete(subTitle);
    }
    collect();
    for (const [subTitle, rec] of cards) {
      update(subTitle, rec);
    }
  }

  function queueScan() {
    if (scanQueued) return;
    scanQueued = true;
    setTimeout(() => {
      scanQueued = false;
      try {
        applyAll();
      } catch (err) {
        console.warn("[Jable-Tools] update failed:", err);
      }
    }, 150);
  }

  /** Current page per the site's pagination (dataset-aware, see `pageOf`). */
  function activePage() {
    const pagination = document.querySelector("ul.pagination");
    return pagination
      ? pageOf(pagination.querySelector(".page-link.active"))
      : null;
  }
  // The site's pagination is AJAX (`data-action="ajax"`): clicking a page
  // link re-renders the grid in place, no navigation, URL unchanged. Track
  // the active page so read-ahead re-syncs and always follows the page on
  // screen (otherwise the appended pages stay the ones fetched before the
  // click).
  let lastActivePage = activePage();
  // The sort the pagination was using last time we checked; the site's
  // stock links always carry the current one in data-parameters.
  let lastSort = null;

  // The label we last wrote into the site's active span (see
  // syncPagination); the site itself always writes a stock page number
  // there when it re-renders, so a changed text is the page-change signal.
  let spanLabelSet = null;

  const observer = new MutationObserver((mutations) => {
    // The site's pagination is AJAX (`data-action="ajax"`): clicking a page
    // link re-renders the grid in place — no navigation, URL unchanged.
    // Detect the move: the site writes a stock page number into the active
    // span (fresh element, or the same element with updated text); if that
    // number differs from what we last saw (resp. from the label we wrote),
    // the page moved and the read-ahead must re-sync.
    let cur = null;
    const span = document.querySelector("ul.pagination .page-link.active");
    if (span) {
      const t = Number.parseInt((span.textContent || "").trim(), 10);
      if (Number.isFinite(t)) {
        const known = span.dataset.jtOurs
          ? Number(spanLabelSet || "")
          : lastActivePage;
        if (Number.isFinite(known) && t !== known) cur = t;
      }
    }
    let pagDirty = false;
    if (cur !== null && cur !== lastActivePage) {
      lastActivePage = cur;
      aheadToken++; // invalidate any in-flight sync
      for (const page of ahead.splice(0)) {
        for (const cell of page.cells) cell.remove();
      }
      syncReadAhead();
      pagDirty = true;
    }
    let sortAttrDirty = false;
    let gridTouched = false;
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.nodeType !== Node.ELEMENT_NODE) continue;
        if (node.matches?.("p.sub-title") || node.querySelector?.("p.sub-title")) {
          queueScan();
          gridTouched = true;
        }
        if (
          node.matches?.(".page-link") ||
          node.querySelector?.(".page-link") ||
          node.closest?.("ul.pagination")
        ) {
          pagDirty = true;
        }
      }
      // The site rewrites the stock links' data-parameters (which includes
      // sort_by) when the user changes the sort — an attribute mutation, no
      // new nodes.
      if (
        mutation.type === "attributes" &&
        mutation.attributeName === "data-parameters" &&
        !mutation.target.dataset.jtOurs
      ) {
        sortAttrDirty = true;
      }
    }
    if (pagDirty || sortAttrDirty) {
      const sortNow = sortOf(document.querySelector("ul.pagination"));
      if (sortNow !== lastSort) {
        lastSort = sortNow;
        aheadToken++; // invalidate any in-flight sync
        for (const page of ahead.splice(0)) {
          for (const cell of page.cells) cell.remove();
        }
        syncPagination(); // re-learn the template (data-parameters now sorted)
        syncReadAhead(); // fetch the ahead pages with the new sort
      } else if (pagDirty) {
        syncPagination();
      }
    }
    // Any grid re-render (re-sort to the same sort, site-side refresh, …)
    // removes our appended cells: re-fetch them. No-op when they survived.
    if (gridTouched) syncReadAhead();
  });

  async function start() {
    let raw = null;
    try {
      raw = await window.JT_HOST.load();
    } catch {
      raw = null;
    }
    config = window.JT.normalize(raw);

    // Live config updates (popup / ⚙ panel / another tab).
    window.JT_HOST.onChange((newRaw) => {
      config = window.JT.normalize(newRaw);
      queueScan();
      syncReadAhead();
      syncPagination();
    });

    applyAll();
    syncPagination();
    lastSort = sortOf(document.querySelector("ul.pagination"));
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["data-parameters"],
    });
    syncReadAhead();
  }

  if (document.body) start();
  else window.addEventListener("DOMContentLoaded", start, { once: true });
})();


window.JT_PAGE_CSS = "/* Jable-Tools ratio badge (injected into jable.tv video cards). */\n.jt-ratio {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  margin-left: 12px;\n  padding: 1px 6px;\n  border-radius: 6px;\n  background: rgba(0, 0, 0, 0.55);\n  color: #fff;\n  font-size: 12px;\n  line-height: 1.4;\n  vertical-align: middle;\n  user-select: none;\n}\n\n.jt-ratio-icon {\n  display: block;\n  fill: currentColor;\n}\n\n.jt-ratio-num {\n  font-variant-numeric: tabular-nums;\n}\n\n/* Jable-Tools floating settings panel (userscript, page context).\n * Scoped to #jt-ui-* / .tier-row inside #jt-ui-root so it cannot leak\n * into the page. The badge styles themselves live in styles.css. */\n\n#jt-ui-btn {\n  position: fixed;\n  right: 16px;\n  bottom: 16px;\n  z-index: 2147483646;\n  width: 34px;\n  height: 34px;\n  border: 1px solid #374151;\n  border-radius: 50%;\n  background: #111827;\n  color: #f3f4f6;\n  font-size: 17px;\n  line-height: 1;\n  cursor: pointer;\n  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);\n  opacity: 0.8;\n}\n#jt-ui-btn:hover {\n  opacity: 1;\n}\n\n#jt-ui-root {\n  position: fixed;\n  right: 16px;\n  bottom: 58px;\n  z-index: 2147483647;\n  width: 320px;\n  max-height: 80vh;\n  overflow: auto;\n  background: #111827;\n  color: #f3f4f6;\n  border: 1px solid #374151;\n  border-radius: 10px;\n  font: 13px system-ui, sans-serif;\n  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);\n}\n\n#jt-ui-root header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 10px 12px;\n  border-bottom: 1px solid #374151;\n}\n#jt-ui-root h1 {\n  font-size: 14px;\n  margin: 0;\n}\n\n/* master on/off slider (same markup as the extension popup) */\n#jt-ui-root .switch {\n  position: relative;\n  display: inline-block;\n  width: 40px;\n  height: 22px;\n}\n#jt-ui-root .switch input {\n  opacity: 0;\n  width: 0;\n  height: 0;\n}\n#jt-ui-root .switch .slider {\n  position: absolute;\n  inset: 0;\n  background: #4b5563;\n  border-radius: 999px;\n  transition: background 0.15s;\n  cursor: pointer;\n}\n#jt-ui-root .switch .slider::before {\n  content: \"\";\n  position: absolute;\n  width: 16px;\n  height: 16px;\n  left: 3px;\n  top: 3px;\n  background: #fff;\n  border-radius: 50%;\n  transition: transform 0.15s;\n}\n#jt-ui-root .switch input:checked + .slider {\n  background: #22c55e;\n}\n#jt-ui-root .switch input:checked + .slider::before {\n  transform: translateX(18px);\n}\n\n#jt-ui-root .hint {\n  padding: 8px 12px 2px;\n  margin: 0;\n  color: #9ca3af;\n  font-size: 11px;\n}\n#jt-ui-root .hint.inline {\n  padding: 0;\n}\n\n/* column header + tier rows share the same grid */\n#jt-ui-root .jt-cols,\n#jt-ui-root .tier-row {\n  display: grid;\n  grid-template-columns: 20px 64px 1fr 64px;\n  gap: 4px 8px;\n  align-items: center;\n  padding: 3px 12px;\n}\n#jt-ui-root .jt-cols {\n  color: #9ca3af;\n  font-size: 11px;\n}\n#jt-ui-root .tier-row {\n  border-top: 1px solid #1f2937;\n}\n\n#jt-ui-root .tier-row input[type=\"checkbox\"] {\n  width: 15px;\n  height: 15px;\n  justify-self: center;\n  accent-color: #22c55e;\n}\n#jt-ui-root .tier-row input[type=\"text\"] {\n  width: 100%;\n  min-width: 0;\n  background: transparent;\n  border: none;\n  color: inherit;\n  font: inherit;\n  padding: 0;\n}\n#jt-ui-root .tier-row input[type=\"text\"]:focus {\n  outline: 1px solid #3b82f6;\n}\n#jt-ui-root .tier-row input[type=\"color\"] {\n  width: 100%;\n  height: 18px;\n  padding: 0;\n  border: none;\n  background: none;\n  cursor: pointer;\n}\n#jt-ui-root .tier-row input[type=\"number\"] {\n  width: 100%;\n  min-width: 0;\n  background: #1f2937;\n  border: 1px solid #374151;\n  color: inherit;\n  border-radius: 4px;\n  padding: 2px 5px;\n  font: inherit;\n}\n#jt-ui-root .tier-row input[type=\"number\"]:focus {\n  outline: 1px solid #3b82f6;\n}\n\n#jt-ui-root .jt-ahead {\n  display: grid;\n  grid-template-columns: 1fr 64px;\n  gap: 8px;\n  align-items: center;\n  padding: 6px 12px 2px;\n  border-top: 1px solid #1f2937;\n}\n#jt-ui-root .jt-ahead input[type=\"number\"] {\n  width: 100%;\n  min-width: 0;\n  background: #1f2937;\n  border: 1px solid #374151;\n  color: inherit;\n  border-radius: 4px;\n  padding: 2px 5px;\n  font: inherit;\n}\n#jt-ui-root .jt-ahead input[type=\"number\"]:focus {\n  outline: 1px solid #3b82f6;\n}\n\n#jt-ui-root footer {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 10px 12px;\n  border-top: 1px solid #374151;\n  margin-top: 6px;\n}\n#jt-ui-root button {\n  background: #1f2937;\n  color: inherit;\n  border: 1px solid #374151;\n  border-radius: 6px;\n  padding: 4px 10px;\n  cursor: pointer;\n  font: inherit;\n}\n#jt-ui-root button:hover {\n  background: #273449;\n}\n\n#jt-ui-root.disabled .jt-cols,\n#jt-ui-root.disabled .tier-row,\n#jt-ui-root.disabled .jt-ahead {\n  opacity: 0.45;\n  pointer-events: none;\n}\n";

/**
 * Jable-Tools — floating settings panel (userscript, page context).
 *
 * A small ⚙ button (bottom right) opens the same settings as the extension
 * popup: master on/off, per-tier show checkbox / name / color / boundary,
 * and reset. Changes are persisted through window.JT_HOST and applied
 * live — no reload.
 *
 * All markup carries `jt-ui` ids/classes; all styles come from
 * `window.JT_PAGE_CSS` (inlined by the build: styles.css + panel.css).
 */
(() => {
  "use strict";

  if (window.__jtPanelMounted) return;
  window.__jtPanelMounted = true;

  const styleEl = document.createElement("style");
  styleEl.id = "jt-ui-styles";
  styleEl.textContent = window.JT_PAGE_CSS || "";

  const btn = document.createElement("button");
  btn.id = "jt-ui-btn";
  btn.type = "button";
  btn.title = "Jable-Tools settings";
  btn.textContent = "⚙";

  const root = document.createElement("div");
  root.id = "jt-ui-root";
  root.style.display = "none";

  const header = document.createElement("header");
  const h1 = document.createElement("h1");
  h1.textContent = "Jable-Tools";
  const switchLabel = document.createElement("label");
  switchLabel.className = "switch";
  switchLabel.title = "Enable / disable the script";
  const enabled = document.createElement("input");
  enabled.type = "checkbox";
  const slider = document.createElement("span");
  slider.className = "slider";
  switchLabel.append(enabled, slider);
  header.append(h1, switchLabel);

  const hint = document.createElement("p");
  hint.className = "hint";
  hint.textContent = "score = likes ÷ views × 10000, capped at 200";

  const cols = document.createElement("div");
  cols.className = "jt-cols";
  cols.innerHTML = "<span></span><span>show</span><span>tier</span><span>score&nbsp;&lt;</span>";

  const tiers = document.createElement("div");
  tiers.id = "jt-ui-tiers";

  const aheadRow = document.createElement("div");
  aheadRow.className = "jt-ahead";
  const aheadLabel = document.createElement("label");
  aheadLabel.htmlFor = "jt-ui-pages-ahead";
  aheadLabel.textContent = "read ahead pages";
  const aheadInput = document.createElement("input");
  aheadInput.type = "number";
  aheadInput.id = "jt-ui-pages-ahead";
  aheadInput.min = "0";
  aheadInput.max = "9";
  aheadInput.step = "1";
  aheadRow.append(aheadLabel, aheadInput);
  const aheadHint = document.createElement("p");
  aheadHint.className = "hint";
  aheadHint.textContent = "fetches the next N listing pages and appends their cards (0 = off)";

  const footer = document.createElement("footer");
  const reset = document.createElement("button");
  reset.type = "button";
  reset.title = "Restore default colors and boundaries";
  reset.textContent = "Reset defaults";
  const live = document.createElement("span");
  live.className = "hint inline";
  live.textContent = "changes apply live";
  footer.append(reset, live);

  root.append(header, hint, cols, tiers, aheadRow, aheadHint, footer);

  function attach() {
    (document.head || document.documentElement).appendChild(styleEl);
    (document.body || document.documentElement).append(root, btn);
  }
  if (document.body) attach();
  else window.addEventListener("DOMContentLoaded", attach, { once: true });

  let config = null;

  function save() {
    window.JT_HOST.save(window.JT.forStorage(config));
  }

  function setEnabled(value) {
    config.enabled = value;
    root.classList.toggle("disabled", !value);
    enabled.checked = value;
  }

  function render() {
    window.JT.mountTierRows(tiers, config, save);
    aheadInput.value = String(config.pagesAhead);
  }

  aheadInput.addEventListener("change", () => {
    const n = Math.floor(Number(aheadInput.value));
    config.pagesAhead = Number.isFinite(n) ? Math.min(9, Math.max(0, n)) : 0;
    aheadInput.value = String(config.pagesAhead);
    save();
  });

  btn.addEventListener("click", async () => {
    const wasHidden = root.style.display === "none";
    if (!wasHidden) {
      root.style.display = "none";
      return;
    }
    if (!config) {
      const raw = await window.JT_HOST.load();
      config = window.JT.normalize(raw);
      render();
    }
    setEnabled(config.enabled);
    root.style.display = "";
  });

  enabled.addEventListener("change", () => {
    setEnabled(enabled.checked);
    save();
  });

  reset.addEventListener("click", () => {
    config = window.JT.normalize(null);
    save();
    render();
    setEnabled(config.enabled);
  });
})();