Supjav Tools

Deep search filter, 1-click open-in-new-tab, tab manager, infinite scroll, and download panel for supjav.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Supjav Tools
// @namespace    supjav-tools
// @version      0.5.3
// @description  Deep search filter, 1-click open-in-new-tab, tab manager, infinite scroll, and download panel for supjav.
// @match        https://*.supjav.com/*
// @match        http://*.supjav.com/*
// @grant        GM_download
// @grant        GM_info
// @grant        GM_xmlhttpRequest
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // Tweak these if the site markup changes. itemSelectors = movie card wrappers,
  // paginationSelectors/nextLinkSelectors = pager links, searchFormSelectors = search form.
  const CONFIG = {
    gridContainerSelectors: ['main', '#main', '.site-main', '.container', '.wrap', '.content', '.list', '.video-list', '.posts', 'body'],
    itemSelectors: ['article', '.item', '.post', '.movie', '.box', '.video-card', '.video-item', '.thumb', '.grid-item'],
    paginationSelectors: ['.pagination', '.navigation', '.nav-links', '.page-nav', '.pager', '.wp-pagenavi'],
    nextLinkSelectors: ['a.next', '.next', 'a.nextpostslink', 'a[rel="next"]', 'li.next a'],
    searchFormSelectors: ['form[role="search"]', 'form.search-form', 'form.search', 'form[action*="supjav"]'],
  };

  // Current code version. VM's GM_info reports the installed copy; the literal
  // keeps desktop/extension embeds (where the metadata header is stripped) in sync.
  const SCRIPT_VERSION =
    (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) ||
    '0.5.3';

   let mediaFromNetwork = [];
   let perfObserverStarted = false;

   // The CDN's Mouflon pkey rotates continuously; the freshest token is always
   // the one the player's live media-playlist requests carry. Track the newest
   // observed token so downloads re-issue the media URL before it rotates out.
   const _pkeyAt = {};
   let _pkeyLast = null;
   function pkeyOf(url) {
     const m = typeof url === 'string' ? url.match(/[?&]pkey=([A-Za-z0-9]+)/) : null;
     return m ? m[1] : null;
   }
   function noteMediaUrl(url) {
     const pkey = pkeyOf(url);
     if (!pkey) return;
     const at = Date.now();
     _pkeyAt[pkey] = at;
     if (!_pkeyLast || _pkeyLast.at <= at) _pkeyLast = { pkey, at };
   }
   function freshestPkey(url) {
     const fromUrl = pkeyOf(url);
     const atUrl = fromUrl ? _pkeyAt[fromUrl] || 0 : -1;
     if (!_pkeyLast) return fromUrl || null;
     return _pkeyLast.at > atUrl ? _pkeyLast.pkey : fromUrl || _pkeyLast.pkey;
   }

   // DOM element cache: .btn-server/.btn-down/iframe lists are static per page,
   // so querying them once avoids repeated full-document scans. Never cache the
   // *result* of detectVideoSources() — new HLS streams appear over time and the
   // auto-monitor/panel refresh must see them.
   let domCache = null;
   function getCachedElement(selector) {
     if (!domCache) domCache = {};
     if (!domCache[selector]) {
       domCache[selector] = Array.from(document.querySelectorAll(selector));
     }
     return domCache[selector];
   }
   
   const matches = (root, selectors) => root.querySelector(selectors.join(','));
   
   function startPerformanceObserver() {
     if (perfObserverStarted) return;
     perfObserverStarted = true;
     try {
        const obs = new PerformanceObserver((list) => {
          list.getEntries().forEach((e) => {
            // Only playlists, never MSE segment files (init/chunk .mp4/.m4s).
            if (/\.(m3u8|mpd)(\?|$)/i.test(e.name)) {
              noteMediaUrl(e.name);
              if (!mediaFromNetwork.includes(e.name)) {
                mediaFromNetwork.push(e.name);
              }
            }
          });
        });
       obs.observe({ type: 'resource', buffered: true });
     } catch (e) {}
   }
   
   const SUPJAV_TOOLS = {
    // ---------------------------------------------------------------- source detection
    detectVideoSources() {
      const sources = [];
      const push = (url, kind, label) => {
        if (url && typeof url === 'string') {
          const clean = url.trim();
          if (clean && !sources.some((s) => s.url === clean)) {
            let host = '';
            try {
              host = new URL(clean).hostname;
            } catch (e) {}
            sources.push({ url: clean, kind, label: label || host });
          }
        }
      };
      const mediaKind = (u) => (/\.(m3u8|mpd)(\?|$)/i.test(u) ? 'hls' : 'video');

      const videoIframe = document.querySelector('iframe#video, iframe[allowfullscreen]');
      if (videoIframe) push(videoIframe.src, 'iframe', 'Player iframe');

      const servers = getCachedElement('.btn-server');
      servers.forEach((a) => {
        push(a.href || a.dataset.src, 'server', `Server ${(a.textContent || '').trim() || 'server'}`);
      });
      const links = getCachedElement('.btn-down');
      links.forEach((a) => {
        push(a.href, 'link', `Link ${(a.textContent || '').trim() || 'link'}`);
      });

      const iframes = getCachedElement('iframe');
      iframes.forEach((f) => {
        let doc;
        try {
          doc = f.contentDocument;
        } catch (e) {
          return;
        }
        if (!doc) return;
        const videos = doc.querySelectorAll('video');
        for (const v of videos) {
          push(v.currentSrc || v.src, 'video', 'Video tag');
          for (const s of v.querySelectorAll('source')) push(s.src, 'video', 'Video tag');
        }
        const txt = Array.from(doc.scripts).map((s) => s.textContent).join(' ');
        const hlsMatches = txt.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/g) || [];
        for (const u of hlsMatches) push(u, 'hls', 'HLS playlist');
        const mp4Matches = txt.match(/https?:\/\/[^'"\s]+\.mp4[^'"\s]*/g) || [];
        for (const u of mp4Matches) push(u, 'video', 'MP4 file');
      });

      const videos = document.querySelectorAll('video');
      for (const v of videos) {
        for (const u of [v.currentSrc, v.src]) {
          if (/^https?:\/\//i.test(u || '')) push(u, mediaKind(u), 'Video tag');
        }
        for (const s of v.querySelectorAll('source')) {
          if (/^https?:\/\//i.test(s.src || '')) push(s.src, mediaKind(s.src), 'Video tag');
        }
      }

      const scriptText = Array.from(document.scripts).map((s) => s.textContent).join(' ');
      const hlsMatches = scriptText.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/g) || [];
      for (const u of hlsMatches) push(u, 'hls', 'HLS playlist');

      const mp4Matches = scriptText.match(/https?:\/\/[^'"\s]+\.mp4[^'"\s]*/g) || [];
      for (const u of mp4Matches) push(u, 'video', 'MP4 file');

      for (const k of ['playerData', 'videoData', 'player_config', 'sources']) {
        let v;
        try {
          v = window[k];
        } catch (e) {
          continue;
        }
        if (v && typeof v === 'object') {
          const raw = JSON.stringify(v);
          const hlsMatches = (raw.match(/https?:\/\/[^"\\\s]+\.m3u8[^"\\\s]*/g) || []);
          for (const u of hlsMatches) push(u, 'hls', 'Player config');
          const mp4Matches = (raw.match(/https?:\/\/[^"\\\s]+\.mp4[^"\\\s]*/g) || []);
          for (const u of mp4Matches) push(u, 'video', 'Player config');
        }
      }

      for (const u of mediaFromNetwork) {
        noteMediaUrl(u);
        push(u, /\.m3u8|\.mpd/i.test(u) ? 'hls' : 'video', 'Live network');
      }

      const resources = performance.getEntriesByType('resource');
      for (const e of resources) {
        if (/\.m3u8(\?|$)/i.test(e.name)) {
          noteMediaUrl(e.name);
          push(e.name, 'hls', 'Network');
        }
      }

      if (window.supjavAPI && typeof window.supjavAPI.getMediaUrls === 'function') {
        try {
          for (const u of window.supjavAPI.getMediaUrls()) {
            push(u, /\.m3u8(\?|$)/i.test(u) ? 'hls' : 'video', 'Desktop capture');
          }
        } catch (e) {}
      }

      return sources;
    },

    // Parses the video code/title/studio from the current page (URL slug + DOM).
    // Used for filename templating and Downloads/<folder>/ organization.
    videoInfo() {
      if (SUPJAV_TOOLS._videoInfo) return SUPJAV_TOOLS._videoInfo;
      const info = { code: '', title: '', studio: '' };
      const slugMatch = location.pathname.match(/\/video\/([^/]+)/i);
      const slug = slugMatch ? decodeURIComponent(slugMatch[1]) : '';
      const codeMatch = slug.match(/([a-z]{2,10})[-_ ]?(\d{2,6})/i);
      if (codeMatch) {
        info.code = `${codeMatch[1]}-${codeMatch[2]}`.toUpperCase();
        info.title = slug.replace(codeMatch[0], '').replace(/[-_]+/g, ' ').trim();
      } else {
        info.title = slug.replace(/[-_]+/g, ' ').trim();
      }
      if (!info.title) {
        const h = document.querySelector('.archive-title h1, h1, .title');
        if (h) info.title = h.textContent;
      }
      const studioEl = document.querySelector(
        '.studio a, a[href*="studio"], .director a, a[href*="release"]',
      );
      if (studioEl) info.studio = studioEl.textContent;
      const safe = (s, max) =>
        (s || '')
          .replace(/\[.*?\]/g, ' ')
          .replace(/[^\w\-. ]+/g, '')
          .replace(/\s+/g, ' ')
          .trim()
          .slice(0, max);
      info.code = safe(info.code, 20).toUpperCase();
      info.title = safe(info.title, 80);
      info.studio = safe(info.studio, 40);
      SUPJAV_TOOLS._videoInfo = info;
      return info;
    },

    videoFolder() {
      return SUPJAV_TOOLS.videoInfo().studio || '';
    },

    defaultFilename(index, kind) {
      const info = SUPJAV_TOOLS.videoInfo();
      const base =
        [info.code, info.title].filter(Boolean).join('_').replace(/\s+/g, '_') ||
        `supjav_${index}`;
      const ext = kind === 'hls' ? 'm3u8' : 'mp4';
      return `${base}_${index}.${ext}`;
    },

    download(url, filename) {
      const folder = SUPJAV_TOOLS.videoFolder();
      if (typeof GM_download === 'function') {
        GM_download(url, filename);
      } else if (window.supjavAPI && typeof window.supjavAPI.download === 'function') {
        window.supjavAPI.download(url, filename, folder);
      } else {
        const a = document.createElement('a');
        a.href = url;
        a.download = filename || '';
        document.body.appendChild(a);
        a.click();
        a.remove();
      }
    },

    ensureToast() {
      if (SUPJAV_TOOLS._toast && document.contains(SUPJAV_TOOLS._toast)) return SUPJAV_TOOLS._toast;
      const t = document.createElement('div');
      t.style.cssText =
        'position:fixed;left:16px;bottom:16px;z-index:2147483647;font:12px/1.4 system-ui,sans-serif;' +
        'color:#eee;background:#1b1b1f;border:1px solid #2e7d32;border-radius:6px;padding:6px 10px;' +
        'max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
      document.body.appendChild(t);
      SUPJAV_TOOLS._toast = t;
      return t;
    },

    copyText(text) {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        try {
          return navigator.clipboard.writeText(text).catch(() => SUPJAV_TOOLS.copyTextFallback(text));
        } catch (e) {
          // Some browsers throw synchronously without clipboard permission/gesture.
          return Promise.resolve(SUPJAV_TOOLS.copyTextFallback(text));
        }
      }
      SUPJAV_TOOLS.copyTextFallback(text);
      return Promise.resolve();
    },

    copyTextFallback(text) {
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      try {
        document.execCommand('copy');
      } catch (e) {
        // execCommand can throw when the page lacks clipboard/focus; nothing to do.
      }
      ta.remove();
    },

    sendToJd(urls) {
      return SUPJAV_TOOLS.copyText(urls.join('\n')).then(() => {
        SUPJAV_TOOLS.ensureToast().textContent =
          `Copied ${urls.length} link${urls.length === 1 ? '' : 's'} — JDownloader LinkGrabber will grab it`;
      });
    },

    sendToIdm(url) {
      const a = document.createElement('a');
      a.className = 'st-idm';
      a.href = url;
      a.style.display = 'none';
      document.body.appendChild(a);
      a.click();
      a.remove();
      SUPJAV_TOOLS.ensureToast().textContent =
        'Sent to IDM — starts a browser fallback download if not intercepted';
    },

    downloadHls(url, filename, row, isAuto) {
      if (!window.supjavAPI || typeof window.supjavAPI.downloadHls !== 'function') {
        SUPJAV_TOOLS.download(url, filename);
        return;
      }
      const target = row || SUPJAV_TOOLS.ensureToast();
      const key = row || SUPJAV_TOOLS._toast;
      if (!key._progSubscribed) {
        key._progSubscribed = true;
        window.supjavAPI.onHlsProgress((p) => {
          if (key._progUrl !== url) return;
          if (p.complete) {
            target.textContent = `Auto-downloaded \u2192 ${p.filename}`;
          } else {
            const pct = Math.round((p.done / p.total) * 100);
            target.textContent = `Auto HLS ${pct}% (${p.done}/${p.total})`;
          }
        });
      }
      key._progUrl = url;
      target.textContent = 'Auto HLS 0%';
      const pkey = freshestPkey(url);
      window.supjavAPI
        .downloadHls(url, filename, !!isAuto, SUPJAV_TOOLS.videoFolder(), pkey)
        .then((res) => {
          if (res && res.started) return; // progress events update the row
          if (res && res.skipped) target.textContent = `Skipped \u2014 ${res.reason || 'already downloaded'}`;
          else if (res && res.ok) target.textContent = `Done \u2192 ${filename}`;
          else if (res && res.error) target.textContent = `HLS error: ${res.error}`;
        });
    },

    // ---------------------------------------------------------------- queue auto-mode (desktop)
    auto: false,
    _autoTried: {},
    _autoTimer: null,

    setAuto(v) {
      SUPJAV_TOOLS.auto = !!v;
      if (SUPJAV_TOOLS.auto) {
        SUPJAV_TOOLS.startAutoMonitor();
      } else if (SUPJAV_TOOLS._autoTimer) {
        clearInterval(SUPJAV_TOOLS._autoTimer);
        SUPJAV_TOOLS._autoTimer = null;
      }
    },

    startAutoMonitor() {
      if (SUPJAV_TOOLS._autoTimer) return;
      SUPJAV_TOOLS._autoTimer = setInterval(() => {
        if (!SUPJAV_TOOLS.auto) {
          clearInterval(SUPJAV_TOOLS._autoTimer);
          SUPJAV_TOOLS._autoTimer = null;
          return;
        }
        const sources = SUPJAV_TOOLS.detectVideoSources();
        const untried = sources.filter((s) => !SUPJAV_TOOLS._autoTried[s.url]);
        const hls = untried.find((s) => s.kind === 'hls');
        if (hls) {
          SUPJAV_TOOLS._autoTried[hls.url] = true;
          SUPJAV_TOOLS.downloadHls(hls.url, SUPJAV_TOOLS.defaultFilename(1, 'hls'), null, true);
          return;
        }
        // Extension-only fallback: with no HLS stream, auto-download the MP4
        // directly. The desktop app merges HLS and lets MP4-only videos hit the
        // queue watchdog, so this is gated to the extension to stay neutral.
        if (window.supjavAPI && window.supjavAPI.runtime === 'extension') {
          const mp4 = untried.find((s) => s.kind === 'mp4');
          if (mp4) {
            SUPJAV_TOOLS._autoTried[mp4.url] = true;
            SUPJAV_TOOLS.download(mp4.url, SUPJAV_TOOLS.defaultFilename(1, 'mp4'));
          }
        }
      }, 2000);
    },

    enqueue(url, title) {
      if (window.supjavAPI && typeof window.supjavAPI.enqueue === 'function') {
        return window.supjavAPI.enqueue(url, title);
      }
      return Promise.resolve(null);
    },

    // ---------------------------------------------------------------- listing page helpers
    findCards(root) {
      const cards = [];
      const seen = new Set();
      const push = (el) => {
        const linkEl = el.matches('a[href]') ? el : el.querySelector('a[href]');
        if (!linkEl || seen.has(linkEl)) return;
        const href = linkEl.href;
        if (!href || href === location.href || href.startsWith('javascript:')) return;
        seen.add(linkEl);
        const titleEl = el.querySelector('h1,h2,h3,.title,.name');
        const img = el.querySelector('img');
        const title = (titleEl ? titleEl.textContent : img ? img.alt : '').trim();
        cards.push({ el, linkEl, href, title, img });
      };
      root.querySelectorAll(CONFIG.itemSelectors.join(',')).forEach(push);
      if (!cards.length) {
        root.querySelectorAll('a[href] img').forEach((img) => {
          const a = img.closest('a[href]');
          if (a) push(a);
        });
      }
      return cards;
    },

    isListPage() {
      if (document.querySelector('#dz_video')) return false;
      return (
        SUPJAV_TOOLS.findCards(document).length >= 2 ||
        matches(document, CONFIG.searchFormSelectors) !== null ||
        SUPJAV_TOOLS.getNextLink() !== null
      );
    },

    _pageNum(href) {
      let m = href.match(/[?&]page=(\d+)/);
      if (m) return parseInt(m[1], 10);
      m = href.match(/[?&]paged=(\d+)/);
      if (m) return parseInt(m[1], 10);
      m = href.match(/\/page\/(\d+)\/?/);
      if (m) return parseInt(m[1], 10);
      return null;
    },

    getNextLink(root) {
      root = root || document;
      const current = location.href;
      for (const sel of CONFIG.paginationSelectors) {
        const pag = root.querySelector(sel);
        if (!pag) continue;
        for (const sel2 of CONFIG.nextLinkSelectors) {
          const a = pag.querySelector(sel2);
          if (a && a.href && a.href !== current) return a.href;
        }
        const any = pag.querySelector('a[href]');
        if (any && any.href && any.href !== current) return any.href;
      }
      const curPage = SUPJAV_TOOLS._pageNum(current) || 1;
      const candidates = [];
      root.querySelectorAll('a[href]').forEach((a) => {
        const href = a.href;
        if (!href || href === current) return;
        const page = SUPJAV_TOOLS._pageNum(href);
        if (page !== null) {
          candidates.push({ href, page });
          return;
        }
        const label = [a.textContent, a.getAttribute('aria-label'), a.getAttribute('title'), a.rel]
          .join(' ')
          .toLowerCase();
        if (/\bnext\b|»|nächste|suivant/.test(label)) {
          candidates.push({ href, page: curPage + 1 });
        }
      });
      candidates.sort((x, y) => x.page - y.page);
      const next = candidates.find((c) => c.page === curPage + 1);
      if (next) return next.href;
      const later = candidates.find((c) => c.page > curPage);
      if (later) return later.href;
      if (candidates.length) return candidates[0].href;
      return null;
    },

    getContainer() {
      if (SUPJAV_TOOLS._containerSel) return document.querySelector(SUPJAV_TOOLS._containerSel);
      for (const sel of CONFIG.gridContainerSelectors) {
        const el = document.querySelector(sel);
        if (el && el.querySelector(CONFIG.itemSelectors.join(','))) {
          SUPJAV_TOOLS._containerSel = sel;
          return el;
        }
      }
      return document.body;
    },

    // ---------------------------------------------------------------- card buttons + filters
    enhanceCards(root) {
      SUPJAV_TOOLS.findCards(root).forEach((c) => {
        if (!c.el.style.position) c.el.style.position = 'relative';
        if (!c.el.querySelector('.st-open')) {
          const b = document.createElement('button');
          b.className = 'st-open';
          b.textContent = 'OPEN';
          b.title = 'Open video in this tab';
          b.style.cssText =
            'position:absolute;right:4px;top:4px;z-index:5;background:#1a73e8;color:#fff;' +
            'border:0;border-radius:4px;padding:2px 8px;font:600 11px/1.6 system-ui,sans-serif;' +
            'cursor:pointer;';
          b.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            location.href = c.href;
          });
          c.el.appendChild(b);
        }
        if (!c.el.querySelector('.st-queue') && window.supjavAPI && typeof window.supjavAPI.enqueue === 'function') {
          const q = document.createElement('button');
          q.className = 'st-queue';
          q.textContent = 'QUEUE';
          q.title = 'Add to desktop download queue';
          q.style.cssText =
            'position:absolute;left:4px;top:4px;z-index:5;background:#2e7d32;color:#fff;' +
            'border:0;border-radius:4px;padding:2px 8px;font:600 11px/1.6 system-ui,sans-serif;' +
            'cursor:pointer;';
          q.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            const r = SUPJAV_TOOLS.enqueue(c.href, c.title || '');
            if (r && typeof r.then === 'function') {
              r.then((res) => {
                if (res && res.rejected) q.textContent = 'Downloaded';
                else if (res && res.queued) q.textContent = 'Added';
                else q.textContent = 'In queue';
                setTimeout(() => (q.textContent = 'QUEUE'), 1200);
              }).catch(() => {});
            } else {
              q.textContent = 'Added';
              setTimeout(() => (q.textContent = 'QUEUE'), 1200);
            }
          });
          c.el.appendChild(q);
        }
        c.el.classList.add('st-card');
      });
    },

    filters: { q: '', include: [], exclude: [], yearMin: '', yearMax: '' },

    applyFilters() {
      const f = SUPJAV_TOOLS.filters;
      const inc = f.include.map((s) => s.trim().toLowerCase()).filter(Boolean);
      const exc = f.exclude.map((s) => s.trim().toLowerCase()).filter(Boolean);
      document.querySelectorAll('.st-card').forEach((el) => {
        const title = (el.textContent || '').toLowerCase();
        let show = true;
        if (inc.length && !inc.every((k) => title.includes(k))) show = false;
        if (show && exc.length && exc.some((k) => title.includes(k))) show = false;
        if (show && (f.yearMin || f.yearMax)) {
          const years = (title.match(/(19|20)\d{2}/g) || []).map(Number);
          if (years.length) {
            if (f.yearMin && years.every((y) => y < f.yearMin)) show = false;
            if (f.yearMax && years.every((y) => y > f.yearMax)) show = false;
          }
        }
        el.style.display = show ? '' : 'none';
      });
    },

    // ---------------------------------------------------------------- infinite scroll
    _nextHref: null,
    _loading: false,
    _loadedPages: new Set(),

    startInfiniteScroll() {
      if (SUPJAV_TOOLS._sentinel) return;
      SUPJAV_TOOLS._nextHref = SUPJAV_TOOLS.getNextLink();
      if (!SUPJAV_TOOLS._nextHref) {
        if (!SUPJAV_TOOLS._retry) {
          SUPJAV_TOOLS._retry = true;
          const iv = setInterval(() => {
            SUPJAV_TOOLS._nextHref = SUPJAV_TOOLS.getNextLink();
            if (SUPJAV_TOOLS._nextHref) {
              clearInterval(iv);
              SUPJAV_TOOLS.startInfiniteScroll();
            }
          }, 1000);
          setTimeout(() => clearInterval(iv), 15000);
        }
        return;
      }
      const container = SUPJAV_TOOLS.getContainer();
      if (!container) return;
      const sentinel = document.createElement('div');
      sentinel.className = 'st-sentinel';
      sentinel.style.cssText = 'height:1px;';
      container.appendChild(sentinel);
      SUPJAV_TOOLS._sentinel = sentinel;
      new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) SUPJAV_TOOLS.loadNextPage();
      }, { rootMargin: '1200px' }).observe(sentinel);
    },

    async loadNextPage() {
      if (SUPJAV_TOOLS._loading || !SUPJAV_TOOLS._nextHref) return;
      if (SUPJAV_TOOLS._loadedPages.has(SUPJAV_TOOLS._nextHref)) {
        SUPJAV_TOOLS._nextHref = null;
        return;
      }
      SUPJAV_TOOLS._loading = true;
      const container = SUPJAV_TOOLS.getContainer();
      const indicator = document.createElement('div');
      indicator.className = 'st-loading';
      indicator.textContent = 'Loading more…';
      indicator.style.cssText =
        'padding:10px;text-align:center;color:#999;font:13px system-ui,sans-serif;';
      if (container && SUPJAV_TOOLS._sentinel)
        container.insertBefore(indicator, SUPJAV_TOOLS._sentinel);
      try {
        const res = await fetch(SUPJAV_TOOLS._nextHref, { credentials: 'same-origin' });
        if (!res.ok) throw new Error(String(res.status));
        const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
        const source = SUPJAV_TOOLS._containerSel
          ? doc.querySelector(SUPJAV_TOOLS._containerSel) || doc.body
          : doc.body;
        const cards = SUPJAV_TOOLS.findCards(source);
        if (!cards.length) {
          SUPJAV_TOOLS._nextHref = null;
          return;
        }
        const existing = new Set(
          Array.from((container || document).querySelectorAll('a[href]')).map((a) => a.href)
        );
        const frag = document.createDocumentFragment();
        cards.forEach((c) => {
          if (existing.has(c.href)) return;
          frag.appendChild(c.el.cloneNode(true));
        });
        if (container && SUPJAV_TOOLS._sentinel)
          container.insertBefore(frag, SUPJAV_TOOLS._sentinel);
        SUPJAV_TOOLS.enhanceCards(container);
        SUPJAV_TOOLS.applyFilters();
        SUPJAV_TOOLS._loadedPages.add(SUPJAV_TOOLS._nextHref);
        SUPJAV_TOOLS._nextHref = SUPJAV_TOOLS.getNextLink(doc);
      } catch (e) {
        SUPJAV_TOOLS._nextHref = null;
      } finally {
        if (indicator && indicator.parentNode) indicator.parentNode.removeChild(indicator);
        SUPJAV_TOOLS._loading = false;
      }
    },

    // ---------------------------------------------------------------- search + filter bar
    buildFilterBar() {
      const bar = document.createElement('div');
      bar.style.cssText =
        'position:sticky;top:0;z-index:9;background:#1b1b1f;color:#eee;padding:8px 10px;' +
        'border-bottom:1px solid #333;font:13px/1.5 system-ui,sans-serif;';

      const row = (label, input, extra) => {
        const w = document.createElement('div');
        w.style.cssText = 'display:inline-flex;align-items:center;gap:6px;margin-right:10px;';
        const l = document.createElement('label');
        l.textContent = label;
        l.style.cssText = 'color:#999;';
        w.appendChild(l);
        w.appendChild(input);
        if (extra) w.appendChild(extra);
        return w;
      };
      const inputStyle =
        'background:#2a2a30;color:#eee;border:1px solid #444;border-radius:4px;padding:4px 6px;font:inherit;';

      const q = document.createElement('input');
      q.placeholder = 'Search site...';
      q.style.cssText = inputStyle + 'width:180px;';
      const go = document.createElement('button');
      go.textContent = 'Search';
      go.style.cssText = btnStyle();
      go.addEventListener('click', () => {
        const v = q.value.trim();
        location.href = location.origin + '/?s=' + encodeURIComponent(v);
      });
      q.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') go.click();
      });

      const inc = document.createElement('input');
      inc.placeholder = 'include words';
      inc.style.cssText = inputStyle + 'width:140px;';
      const exc = document.createElement('input');
      exc.placeholder = 'exclude words';
      exc.style.cssText = inputStyle + 'width:140px;';
      const yMin = document.createElement('input');
      yMin.type = 'number';
      yMin.placeholder = 'Year from';
      yMin.style.cssText = inputStyle + 'width:80px;';
      const yMax = document.createElement('input');
      yMax.type = 'number';
      yMax.placeholder = 'Year to';
      yMax.style.cssText = inputStyle + 'width:80px;';

      const apply = () => {
        SUPJAV_TOOLS.filters.include = inc.value.split(/[\s,]+/);
        SUPJAV_TOOLS.filters.exclude = exc.value.split(/[\s,]+/);
        SUPJAV_TOOLS.filters.yearMin = yMin.value ? parseInt(yMin.value, 10) : '';
        SUPJAV_TOOLS.filters.yearMax = yMax.value ? parseInt(yMax.value, 10) : '';
        SUPJAV_TOOLS.applyFilters();
      };
      const applyBtn = document.createElement('button');
      applyBtn.textContent = 'Filter';
      applyBtn.style.cssText = btnStyle();
      applyBtn.addEventListener('click', apply);
      const reset = document.createElement('button');
      reset.textContent = 'Reset';
      reset.style.cssText = btnStyle();
      reset.addEventListener('click', () => {
        inc.value = exc.value = yMin.value = yMax.value = '';
        SUPJAV_TOOLS.filters = { q: '', include: [], exclude: [], yearMin: '', yearMax: '' };
        SUPJAV_TOOLS.applyFilters();
      });

      bar.appendChild(row('', q, go));
      bar.appendChild(row('Include', inc));
      bar.appendChild(row('Exclude', exc));
      bar.appendChild(row('Year', yMin, yMax));
      bar.appendChild(applyBtn);
      bar.appendChild(reset);

      if (window.supjavAPI && typeof window.supjavAPI.enqueueAll === 'function') {
        const qAll = document.createElement('button');
        qAll.textContent = 'Queue all visible';
        qAll.style.cssText = btnStyle() + 'margin-left:10px;';
        qAll.addEventListener('click', () => {
          const items = [];
          document.querySelectorAll('.st-card').forEach((el) => {
            if (el.style.display === 'none') return;
            const a = el.querySelector('a[href]');
            if (!a) return;
            const titleEl = el.querySelector('h1,h2,h3,.title,.name');
            items.push({
              url: a.href,
              title: (titleEl ? titleEl.textContent : a.getAttribute('title') || '').trim().slice(0, 100),
            });
          });
          if (!items.length) {
            qAll.textContent = 'No visible cards';
            setTimeout(() => (qAll.textContent = 'Queue all visible'), 1500);
            return;
          }
          window.supjavAPI.enqueueAll(items).then((r) => {
            if (r && typeof r.added === 'number') {
              qAll.textContent = `Queued ${r.added} (${r.skipped || 0} dupes)`;
            } else {
              qAll.textContent = 'Queue unavailable';
            }
            setTimeout(() => (qAll.textContent = 'Queue all visible'), 2000);
          }).catch(() => {
            qAll.textContent = 'Queue unavailable';
            setTimeout(() => (qAll.textContent = 'Queue all visible'), 1500);
          });
        });
        bar.appendChild(qAll);
      }

      function btnStyle() {
        return (
          'background:#3a3a42;color:#eee;border:1px solid #555;border-radius:4px;' +
          'padding:4px 10px;cursor:pointer;font:inherit;'
        );
      }
      return bar;
    },

    // Serializes everything the tool "sees" on the current page so it can be
    // pasted back to verify live detection without automated site access.
    collectDebugReport() {
      const sources = SUPJAV_TOOLS.detectVideoSources();
      return {
        scriptVersion: SCRIPT_VERSION,
        url: location.href,
        title: (document.title || '').slice(0, 160),
        isList: !!SUPJAV_TOOLS.isListPage(),
        next: SUPJAV_TOOLS.getNextLink ? SUPJAV_TOOLS.getNextLink() : null,
        sourceCount: sources.length,
        sources: sources.map((s) => ({ kind: s.kind, url: s.url, label: s.label })),
        extMarker: document.documentElement.getAttribute('data-st-ext'),
        supjavApi:
          !!window.supjavAPI && typeof window.supjavAPI.enqueue === 'function',
      };
    },

    // ---------------------------------------------------------------- floating panel (sources + tabs)
    buildPanel() {
      const panel = document.createElement('div');
      panel.style.cssText =
        'position:fixed;right:16px;bottom:16px;z-index:2147483647;font:13px/1.5 system-ui,sans-serif;' +
        'color:#eee;background:#1b1b1f;border:1px solid #333;border-radius:8px;padding:10px 12px;' +
        'box-shadow:0 4px 20px rgba(0,0,0,.5);width:340px;max-height:70vh;overflow:auto;';

      const title = document.createElement('div');
      title.textContent = 'Supjav Tools';
      title.style.cssText = 'font-weight:600;margin-bottom:8px;color:#fff;';
      panel.appendChild(title);

      const label = (text) => {
        const d = document.createElement('div');
        d.textContent = text;
        d.style.cssText = 'font-weight:600;margin:10px 0 4px;color:#bbb;';
        return d;
      };

      const list = document.createElement('div');
      panel.appendChild(label('Video sources'));
      panel.appendChild(list);

      const refresh = () => {
        const sources = SUPJAV_TOOLS.detectVideoSources();
        if (JSON.stringify(sources) === panel._lastKey) return;
        panel._lastKey = JSON.stringify(sources);
        list.replaceChildren();
        if (!sources.length) {
          const empty = document.createElement('div');
          empty.textContent = 'No video sources detected on this page.';
          empty.style.cssText = 'color:#999;';
          list.appendChild(empty);
          return;
        }
        sources.forEach((s, i) => {
          const wrap = document.createElement('div');
          wrap.style.cssText = 'display:flex;gap:4px;margin:4px 0;';
          const row = document.createElement('button');
          row.textContent = `${i + 1}. ${s.kind.toUpperCase()} — ${s.label}`;
          row.title = s.url;
          row.style.cssText =
            'flex:1;padding:6px 8px;text-align:left;background:#2a2a30;color:#eee;' +
            'border:1px solid #444;border-radius:6px;cursor:pointer;overflow:hidden;' +
            'text-overflow:ellipsis;white-space:nowrap;';
          row.addEventListener('click', () => {
            const filename = SUPJAV_TOOLS.defaultFilename(i + 1, s.kind);
            if (s.kind === 'hls') {
              SUPJAV_TOOLS.downloadHls(s.url, filename, row);
            } else {
              SUPJAV_TOOLS.download(s.url, filename);
            }
          });
          wrap.appendChild(row);
          const mini = (label, title, fn) => {
            const b = document.createElement('button');
            b.textContent = label;
            b.title = title;
            b.style.cssText =
              'padding:6px 8px;background:#1b1b1f;color:#eee;border:1px solid #444;' +
              'border-radius:6px;cursor:pointer;font:600 11px/1.4 system-ui,sans-serif;';
            b.addEventListener('click', fn);
            wrap.appendChild(b);
          };
          mini(
            'IDM',
            'Send to Internet Download Manager (IDM browser extension must be installed to intercept)',
            () => SUPJAV_TOOLS.sendToIdm(s.url),
          );
          mini(
            'JD',
            'Copy link to clipboard for JDownloader LinkGrabber (enable clipboard monitoring in JDownloader settings)',
            () => SUPJAV_TOOLS.sendToJd([s.url]),
          );
          list.appendChild(wrap);
        });
      };

      // auto-monitor section
      const autoBox = document.createElement('div');
      autoBox.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;';
      const autoChk = document.createElement('input');
      autoChk.type = 'checkbox';
      autoChk.checked = SUPJAV_TOOLS.auto;
      autoChk.addEventListener('change', () => {
        SUPJAV_TOOLS.setAuto(autoChk.checked);
        localStorage.setItem('st_auto', autoChk.checked ? '1' : '0');
      });
      const autoLbl = document.createElement('span');
      autoLbl.textContent = 'Auto-monitor: auto-download video when a stream appears';
      autoLbl.style.cssText = 'color:#ccc;';
      autoBox.appendChild(autoChk);
      autoBox.appendChild(autoLbl);

      panel.appendChild(label('Auto-monitor'));
      panel.appendChild(autoBox);

      if (window.supjavAPI && typeof window.supjavAPI.enqueue === 'function') {
        const q = document.createElement('button');
        q.textContent = 'Add this video to queue';
        q.style.cssText = btnStyle() + 'width:100%;margin-top:8px;';
        q.addEventListener('click', () => {
          const t = (document.querySelector('.archive-title h1') || document.body)
            .textContent.trim()
            .slice(0, 100);
          SUPJAV_TOOLS.enqueue(location.href, t).then((r) => {
            if (r && r.queued) q.textContent = `Queued (${r.queue} in queue)`;
            else if (r && r.rejected) q.textContent = 'Already downloaded';
            else if (r && !r.queued) q.textContent = 'Already queued';
            else q.textContent = 'Queue unavailable';
            setTimeout(() => (q.textContent = 'Add this video to queue'), 2000);
          });
        });
        panel.appendChild(q);
      }

      // history section (desktop only)
      let historyBox = null;
      if (window.supjavAPI && typeof window.supjavAPI.getHistory === 'function') {
        historyBox = document.createElement('div');
        historyBox.style.cssText = 'color:#999;font:12px/1.4 system-ui,sans-serif;';
        const clearHist = document.createElement('button');
        clearHist.textContent = 'Clear history';
        clearHist.style.cssText = btnStyle() + 'margin-left:6px;';
        clearHist.addEventListener('click', () => {
          window.supjavAPI
            .clearHistory()
            .then(() => renderHistory())
            .catch(() => {});
        });
        const histLabel = label('History');
        histLabel.style.display = 'flex';
        histLabel.style.justifyContent = 'space-between';
        histLabel.style.alignItems = 'center';
        histLabel.appendChild(clearHist);
        panel.appendChild(histLabel);
        panel.appendChild(historyBox);
        const renderHistory = () => {
          window.supjavAPI
            .getHistory()
            .then((items) => {
              historyBox.replaceChildren();
              if (!items || !items.length) {
                historyBox.textContent = 'No downloads yet.';
                return;
              }
              items.slice(0, 8).forEach((h) => {
                const row = document.createElement('div');
                row.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
                const when = new Date(h.time).toLocaleTimeString([], {
                  hour: '2-digit',
                  minute: '2-digit',
                });
                row.textContent = `${when} ${h.folder ? h.folder + '/' : ''}${h.filename} [${h.status}]`;
                row.title = h.url || '';
                historyBox.appendChild(row);
              });
            })
            .catch(() => {});
        };
        renderHistory();
        SUPJAV_TOOLS._renderHistory = renderHistory;
      }

      const foot = document.createElement('div');
      foot.style.cssText =
        'margin-top:10px;padding-top:8px;border-top:1px solid #333;font:11px system-ui,sans-serif;' +
        'color:#777;display:flex;justify-content:space-between;align-items:center;';
      const ver = document.createElement('span');
      ver.textContent = `Supjav Tools v${SCRIPT_VERSION}`;
      foot.appendChild(ver);
      if (SUPJAV_TOOLS._updateInfo) {
        const up = document.createElement('a');
        up.textContent = `Update v${SUPJAV_TOOLS._updateInfo.from} \u2192 v${SUPJAV_TOOLS._updateInfo.to}`;
        up.href = 'http://127.0.0.1:28719/';
        up.target = '_blank';
        up.style.cssText = 'color:#81c784;';
        foot.appendChild(up);
      }
      panel.appendChild(foot);

      const dbg = document.createElement('button');
      dbg.textContent = 'Copy debug report';
      dbg.title = 'Copy what this page looks like to the tool (URL, sources, pagination) so it can be verified without automated site access';
      dbg.style.cssText = btnStyle() + 'width:100%;margin-top:8px;';
      dbg.addEventListener('click', () => {
        SUPJAV_TOOLS.copyText(JSON.stringify(SUPJAV_TOOLS.collectDebugReport(), null, 2)).then(() => {
          SUPJAV_TOOLS.ensureToast().textContent =
            'Debug report copied \u2014 paste it back to the assistant';
        });
      });
      panel.appendChild(dbg);

      const close = document.createElement('button');
      close.textContent = 'Close';
      close.style.cssText =
        'margin-top:10px;width:100%;padding:5px;background:transparent;color:#999;' +
        'border:1px solid #444;border-radius:6px;cursor:pointer;';
      close.addEventListener('click', () => {
        panel._timer && clearInterval(panel._timer);
        panel.remove();
        SUPJAV_TOOLS.fab.style.display = 'flex';
      });
      panel.appendChild(close);

      panel._timer = setInterval(() => {
        refresh();
        if (SUPJAV_TOOLS._renderHistory) SUPJAV_TOOLS._renderHistory();
      }, 2000);
      refresh();
      if (SUPJAV_TOOLS._renderHistory) SUPJAV_TOOLS._renderHistory();
      return panel;

      function btnStyle() {
        return (
          'background:#3a3a42;color:#eee;border:1px solid #555;border-radius:4px;' +
          'padding:3px 8px;cursor:pointer;font:inherit;'
        );
      }
    },

    versionGt(a, b) {
      const left = String(a || '')
        .split('.')
        .map((n) => parseInt(n, 10) || 0);
      const right = String(b || '')
        .split('.')
        .map((n) => parseInt(n, 10) || 0);
      for (let i = 0; i < Math.max(left.length, right.length); i++) {
        const x = left[i] || 0;
        const y = right[i] || 0;
        if (x !== y) return x > y;
      }
      return false;
    },

    // The installer server (install/installer.js) answers /version; GM_xmlhttpRequest
    // bypasses the mixed-content block so an https supjav page can reach http://127.0.0.1.
    checkForUpdate() {
      if (typeof GM_xmlhttpRequest !== 'function') return;
      try {
        const last = parseInt(localStorage.getItem('st_update_check') || '0', 10);
        if (Date.now() - last < 6 * 3600 * 1000) {
          SUPJAV_TOOLS._updateInfo = JSON.parse(localStorage.getItem('st_update_info') || 'null');
          SUPJAV_TOOLS.renderUpdateChip();
          return;
        }
      } catch (e) {
        // localStorage disabled or corrupt; fall through to a live check.
      }
      GM_xmlhttpRequest({
        method: 'GET',
        url: 'http://127.0.0.1:28719/version',
        timeout: 3000,
        onload: (res) => SUPJAV_TOOLS.handleUpdateResponse(res.responseText),
        onerror: () => SUPJAV_TOOLS.renderUpdateChip(),
        ontimeout: () => SUPJAV_TOOLS.renderUpdateChip(),
      });
    },

    handleUpdateResponse(text) {
      const available = String(text || '').trim().replace(/^v/, '');
      SUPJAV_TOOLS._updateInfo =
        available && SUPJAV_TOOLS.versionGt(available, SCRIPT_VERSION)
          ? { from: SCRIPT_VERSION, to: available }
          : null;
      try {
        localStorage.setItem('st_update_check', String(Date.now()));
        localStorage.setItem('st_update_info', JSON.stringify(SUPJAV_TOOLS._updateInfo));
      } catch (e) {
        // localStorage disabled; the chip still renders for this session.
      }
      SUPJAV_TOOLS.renderUpdateChip();
    },

    renderUpdateChip() {
      const id = 'st-update-chip';
      const old = document.getElementById(id);
      if (old) old.remove();
      if (!SUPJAV_TOOLS._updateInfo) return;
      const chip = document.createElement('div');
      chip.id = id;
      chip.textContent = `Supjav Tools update: v${SUPJAV_TOOLS._updateInfo.from} \u2192 v${SUPJAV_TOOLS._updateInfo.to}`;
      chip.style.cssText =
        'position:fixed;top:12px;right:16px;z-index:2147483647;background:#1b1b1f;color:#81c784;' +
        'border:1px solid #2e7d32;border-radius:6px;padding:6px 10px;font:12px system-ui,sans-serif;' +
        'cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.5);';
      chip.addEventListener('click', () => window.open('http://127.0.0.1:28719/', '_blank'));
      document.body.appendChild(chip);
    },

    ensureFab() {
      if (SUPJAV_TOOLS.fab) return;
      const fab = document.createElement('div');
      fab.textContent = 'DL';
      fab.style.cssText =
        'position:fixed;right:16px;bottom:16px;z-index:2147483646;width:44px;height:44px;' +
        'display:flex;align-items:center;justify-content:center;background:#e11;color:#fff;' +
        'border-radius:50%;font:700 14px system-ui,sans-serif;cursor:pointer;' +
        'box-shadow:0 4px 16px rgba(0,0,0,.5);user-select:none;';
      fab.title = 'Supjav Tools';
      fab.dataset.stVersion = SCRIPT_VERSION;
      fab.addEventListener('click', () => {
        fab.style.display = 'none';
        document.body.appendChild(SUPJAV_TOOLS.buildPanel());
      });
      document.body.appendChild(fab);
      SUPJAV_TOOLS.fab = fab;
    },

    // On video pages, if the player iframe is still about:blank, click the first
    // server button so the embed loads (desktop/extension both call this from the
    // top frame before/alongside autoplay in the subframe).
    primeVideoPlayer() {
      if (!location.pathname.includes('/video/')) return;
      const iframe = document.querySelector('iframe#video, iframe[allowfullscreen], iframe');
      const src = iframe && iframe.getAttribute('src');
      if (!src || src === 'about:blank') {
        const servers = getCachedElement('.btn-server');
        if (servers.length) servers[0].click();
      }
    },

    // Runs only in cross-origin embed subframes (extension content script with
    // all_frames). Supjav players fetch the stream only after a play action, so
    // synthesize one: click a play-looking element until a <video> appears, then
    // play() directly (muted first so autoplay is never blocked).
    startEmbedAutoplay() {
      if (SUPJAV_TOOLS._embedStarted) return;
      SUPJAV_TOOLS._embedStarted = true;
      const boot = () => {
        const v = document.querySelector('video');
        if (v) {
          try {
            v.muted = true;
            const p = v.play();
            if (p && p.then) p.then(() => { try { v.muted = false; } catch (e) {} }).catch(() => {});
          } catch (e) {}
          return;
        }
        const cands = Array.from(
          document.querySelectorAll('button, [role=button], a, .play, .vjs-big-play-button, [class*=play i], [id*=play i]'),
        );
        const btn =
          cands.find((el) => {
            const r = el.getBoundingClientRect();
            return r.width > 24 && r.height > 24;
          }) || cands[0];
        if (btn) btn.click();
      };
      boot();
      const iv = setInterval(boot, 1000);
      setTimeout(() => clearInterval(iv), 90000);
      window.__supjavAutoplay = true;
    },

    start() {
      // If the extension is installed, its content script (isolated world) owns
      // the UI and embed autoplay; a co-installed Violentmonkey copy has no
      // supjavAPI but can see the extension's DOM marker, so it must step aside.
      if (!window.supjavAPI && document.documentElement.getAttribute('data-st-ext') === '1') {
        return;
      }
      // In cross-origin embed subframes only run autoplay; never show UI there.
      let isTop = true;
      try {
        isTop = window.top === window;
      } catch (e) {}
      if (!isTop) {
        SUPJAV_TOOLS.startEmbedAutoplay();
        return;
      }
      startPerformanceObserver();
      SUPJAV_TOOLS.ensureFab();
      SUPJAV_TOOLS.primeVideoPlayer();
      SUPJAV_TOOLS.checkForUpdate();
      if (localStorage.getItem('st_auto') === '1') SUPJAV_TOOLS.setAuto(true);
      if (SUPJAV_TOOLS.isListPage()) {
        const bar = SUPJAV_TOOLS.buildFilterBar();
        const container = SUPJAV_TOOLS.getContainer();
        container.parentNode.insertBefore(bar, container);
        SUPJAV_TOOLS.enhanceCards(document);
        SUPJAV_TOOLS.startInfiniteScroll();
      }
      const observer = new MutationObserver(() => {
        if (!document.contains(SUPJAV_TOOLS.fab)) SUPJAV_TOOLS.ensureFab();
      });
      observer.observe(document.body, { childList: true, subtree: true });
    },
  };

  window.__SUPJAV_TOOLS__ = SUPJAV_TOOLS;

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => SUPJAV_TOOLS.start());
  } else {
    SUPJAV_TOOLS.start();
  }
})();