StreamGrabber

Lightweight downloader for HLS (.m3u8 via m3u8-parser), video blobs, and direct videos. Mobile + Desktop. Pause/Resume. AES-128. fMP4. Minimal UI.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         StreamGrabber
// @namespace    https://github.com/streamgrabber-lite
// @version      1.2.3
// @description  Lightweight downloader for HLS (.m3u8 via m3u8-parser), video blobs, and direct videos. Mobile + Desktop. Pause/Resume. AES-128. fMP4. Minimal UI.
// @match        *://*/*
// @exclude      *://*.youtube.com/*
// @exclude      *://*.youtu.be/*
// @exclude      *://*.x.com/*
// @exclude      *://*.twitch.tv/*
// @exclude      *://*.reddit.com/*
// @exclude      *://*.redd.it/*
// @exclude      *://*.facebook.com/*
// @exclude      *://*.instagram.com/*
// @exclude      *://*.tiktok.com/*
// @exclude      *://*.netflix.com/*
// @exclude      *://*.hulu.com/*
// @exclude      *://*.disneyplus.com/*
// @exclude      *://*.primevideo.com/*
// @exclude      *://*.spotify.com/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      *
// @license      MIT
// @require      https://cdnjs.cloudflare.com/ajax/libs/m3u8-parser/7.2.0/m3u8-parser.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// ==/UserScript==
(() => {
  'use strict';

  // =========================
  // Config
  // =========================
  const CFG = {
    RETRIES: 3,
    CONC: 6,
    REQ_MS: 60000,
    MAN_MS: 30000,
    SMALL_BYTES: 1 * 1024 * 1024, // 1MB
    UI_IDLE_MS: 5000, // idle fade delay
  };
  const CACHE = {
    TEXT_MAX: 256,
    HEAD_MAX: 256,
    DB_MAX: 120,
    CLEAR_MS: 120000
  };

  // =========================
  // State & caches
  // =========================
  const DB = {
    m3u8: new Set(),
    vid: new Set(),
  };
  const BLOBS = new Map(); // blobUrl -> { blob, type, size, kind, ts, revoked? }
  const textCache = new Map(); // url -> text (LRU-ish via bump)
  const inflightText = new Map(); // url -> Promise<string>
  const headCache = new Map(); // url -> { length, type } (LRU-ish via bump)
  const inflightHead = new Map(); // url -> Promise<meta>
  const watchedVideos = new Set();

  // Settings
  const SETTINGS = {
    excludeSmall: (() => {
      try { const v = localStorage.getItem('sg_exclude_small'); return v == null ? true : v === 'true'; } catch { return true; }
    })(),
  };
  const setExcludeSmall = (v) => { SETTINGS.excludeSmall = !!v; try { localStorage.setItem('sg_exclude_small', String(!!v)); } catch { } };

  // =========================
  // Utilities
  // =========================
  const log = (...x) => console.log('[SG]', ...x);
  const err = (...x) => console.error('[SG]', ...x);
  const isHttp = (u) => typeof u === 'string' && /^https?:/i.test(u);
  const isBlob = (u) => typeof u === 'string' && /^blob:/i.test(u);
  const isM3U8Url = (u) => /\.m3u8(\b|[?#]|$)/i.test(u || '');
  const isVideoUrl = (u) => /\.(mp4|mkv|webm|avi|mov|m4v|ts|m2ts|flv|ogv|ogg)([?#]|$)/i.test(u || '');
  const looksM3U8Type = (t = '') => /mpegurl|vnd\.apple\.mpegurl|application\/x-mpegurl/i.test(t);
  const looksVideoType = (t = '') => /^video\//i.test(t) || /(matroska|mp4|webm|quicktime)/i.test(t);
  const safeAbs = (u, b) => { try { return new URL(u, b).href; } catch { return u; } };
  const cleanName = (s) => (s || 'video').replace(/[\\/:*?"<>|]/g, '_').slice(0, 120).trim() || 'video';
  const fmtBytes = (n) => { if (n == null) return ''; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0, v = n; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`; };
  const extFromType = (t = '') => {
    t = t.toLowerCase();
    if (t.includes('webm')) return 'webm';
    if (t.includes('matroska') || t.includes('mkv')) return 'mkv';
    if (t.includes('quicktime') || t.includes('mov')) return 'mov';
    if (t.includes('mp2t') || t.includes('mpegts')) return 'ts';
    if (t.includes('ogg')) return 'ogg';
    if (t.includes('mp4')) return 'mp4';
    return 'mp4';
  };
  const guessExt = (url, type) => {
    const m = /(?:\.([a-z0-9]+))([?#]|$)/i.exec(url || ''); return m ? m[1].toLowerCase() : (type ? extFromType(type) : 'mp4');
  };
  function once(cache, inflight, key, loader, max) {
    const inC = lruGet(cache, key);
    if (inC !== undefined) return Promise.resolve(inC);
    if (inflight.has(key)) return inflight.get(key);
    const p = (async () => {
      try { const v = await loader(); lruSet(cache, key, v, max); return v; }
      finally { inflight.delete(key); }
    })();
    inflight.set(key, p);
    return p;
  }
  const parseRange = (v) => {
    if (!v) return null;
    const m = /bytes=(\d+)-(\d+)?/i.exec(v);
    if (!m) return null;
    return { start: +m[1], end: m[2] != null ? +m[2] : null };
  };

  // bounded add helper for DB sets
  function boundedAdd(set, value, max = CACHE.DB_MAX) {
    if (set.has(value)) return false;
    set.add(value);
    while (set.size > max) {
      const first = set.values().next().value;
      set.delete(first);
    }
    return true;
  }
  // LRU-ish helpers for Maps (bump on get, trim on set)
  function lruGet(map, key) {
    if (!map.has(key)) return undefined;
    const v = map.get(key);
    map.delete(key); // bump to end
    map.set(key, v);
    return v;
  }
  function lruSet(map, key, val, max) {
    if (map.has(key)) map.delete(key);
    map.set(key, val);
    if (typeof max === 'number' && isFinite(max)) {
      while (map.size > max) {
        map.delete(map.keys().next().value);
      }
    }
  }
  // passive cache trim (+prune revoked blobs)
  function trimCaches() {
    while (DB.m3u8.size > CACHE.DB_MAX) DB.m3u8.delete(DB.m3u8.values().next().value);
    while (DB.vid.size > CACHE.DB_MAX) DB.vid.delete(DB.vid.values().next().value);
    const now = Date.now();
    for (const [href, info] of BLOBS) {
      const idle = now - (info.ts || 0);
      if (info.revoked && idle > CACHE.CLEAR_MS) {
        BLOBS.delete(href);
        DB.m3u8.delete(href);
        DB.vid.delete(href);
      }
    }
  }
  setInterval(trimCaches, CACHE.CLEAR_MS);
  window.addEventListener('pagehide', trimCaches);
  window.addEventListener('beforeunload', trimCaches);

  // =========================
  // Network helpers
  // =========================
  function gmGet({ url, responseType = 'text', headers = {}, timeout = CFG.REQ_MS, onprogress }) {
    let ref;
    const p = new Promise((resolve, reject) => {
      ref = GM_xmlhttpRequest({
        method: 'GET',
        url, responseType, headers, timeout,
        onprogress: e => onprogress?.(e),
        onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}`)),
        onerror: () => reject(new Error('Network error')),
        ontimeout: () => reject(new Error('Timeout'))
      });
    });
    p.abort = () => { try { ref?.abort(); } catch { } };
    return p;
  }
  const getText = (url) => once(textCache, inflightText, url, async () => {
    if (isBlob(url)) {
      const info = BLOBS.get(url);
      if (!info?.blob) throw new Error('Blob not found');
      info.ts = Date.now();
      return info.blob.text();
    }
    return gmGet({ url, responseType: 'text', timeout: CFG.MAN_MS });
  }, CACHE.TEXT_MAX);
  function getBin(url, headers = {}, timeout = CFG.REQ_MS, onprogress) {
    if (isBlob(url)) {
      const info = BLOBS.get(url);
      if (!info?.blob) return Promise.reject(new Error('Blob not found'));
      info.ts = Date.now();
      const range = parseRange(headers.Range);
      const part = range ? info.blob.slice(range.start, (range.end == null ? info.blob.size : range.end + 1)) : info.blob;
      if (onprogress) setTimeout(() => onprogress({ loaded: part.size, total: part.size }), 0);
      return part.arrayBuffer();
    }
    return gmGet({ url, responseType: 'arraybuffer', headers, timeout, onprogress });
  }
  const headMeta = (url) => once(headCache, inflightHead, url, async () => {
    try {
      const resp = await new Promise((res, rej) => {
        GM_xmlhttpRequest({
          method: 'HEAD', url, timeout: CFG.REQ_MS,
          onload: res, onerror: () => rej(new Error('HEAD failed')),
          ontimeout: () => rej(new Error('HEAD timeout'))
        });
      });
      const h = resp.responseHeaders || '';
      const length = +(/(^|\n)content-length:\s*(\d+)/i.exec(h)?.[2] || 0) || null;
      const type = (/(^|\n)content-type:\s*([^\n]+)/i.exec(h)?.[2] || '').trim() || null;
      return { length, type };
    } catch { return { length: null, type: null }; }
  }, CACHE.HEAD_MAX);

  // =========================
  // Crypto helpers (AES-128/CBC)
  // =========================
  const hexToU8 = (hex) => {
    hex = String(hex || '').replace(/^0x/i, '').replace(/[^0-9a-f]/gi, '');
    if (hex.length % 2) hex = '0' + hex;
    const out = new Uint8Array(hex.length / 2);
    for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
    return out;
  };
  const ivFromSeq = (n) => {
    n = BigInt(n >>> 0); const iv = new Uint8Array(16);
    for (let i = 15; i >= 0; i--) { iv[i] = Number(n & 0xffn); n >>= 8n; }
    return iv;
  };
  async function aesCbcDec(buf, keyBytes, iv) {
    const k = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-CBC' }, false, ['decrypt']);
    return crypto.subtle.decrypt({ name: 'AES-CBC', iv }, k, buf);
  }

  // =========================
  // UI (Compact Dark Minimal)
  // =========================
  GM_addStyle(`
  :root{
    --sg-bg:#1e1e1e;
    --sg-bg-2:#252525;
    --sg-bg-3:#2d2d2d;
    --sg-border:#353535;
    --sg-border-2:#404040;
    --sg-fg:#e0e0e0;
    --sg-fg-dim:#aaa;
    --sg-fg-dimmer:#888;
    --sg-ok:#10b981;
    --sg-bad:#e74c3c;
    --sg-badge:#dc3545;
  }
  @keyframes umdl-spin{to{transform:rotate(360deg)}}
  .umdl-fab{
    position:fixed;right:16px;bottom:16px;z-index:2147483647;
    width:48px;height:48px;border-radius:50%;
    display:none;align-items:center;justify-content:center;
    background:var(--sg-bg-3);color:#fff;border:1px solid var(--sg-border-2);
    cursor:pointer;overflow:visible
  }
  .umdl-fab.show{display:flex}
  .umdl-fab.idle{opacity:.5}
  .umdl-fab:hover{background:#353535}
  .umdl-fab.busy svg{opacity:0}
  .umdl-fab.busy::after{
    content:'';position:absolute;width:18px;height:18px;border:2px solid var(--sg-border-2);
    border-top-color:#fff;border-radius:50%;animation:umdl-spin .6s linear infinite
  }
  .umdl-fab svg{width:16px;height:16px}
  .umdl-badge{
    position:absolute;top:-6px;right:-6px;background:var(--sg-badge);color:#fff;
    font-weight:600;font-size:10px;padding:3px 5px;border-radius:10px;display:none;
    line-height:1;border:2px solid var(--sg-bg);min-width:18px;text-align:center;
    box-shadow:0 2px 4px rgba(0,0,0,.3)
  }
  .umdl-pick{position:fixed;inset:0;z-index:2147483647;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.75);backdrop-filter:blur(4px)}
  .umdl-pick.show{display:flex}
  .umdl-card{
    background:var(--sg-bg);color:var(--sg-fg);border:1px solid var(--sg-border-2);
    border-radius:10px;width:min(500px,94vw);max-height:84vh;overflow:hidden
  }
  .umdl-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid #2d2d2d}
  .umdl-head .ttl{font-size:15px;font-weight:600;color:#fff}
  .umdl-x{
    background:var(--sg-bg-3);border:1px solid var(--sg-border-2);color:var(--sg-fg-dim);
    border-radius:8px;padding:6px;cursor:pointer;display:flex;min-width:32px;min-height:32px
  }
  .umdl-x:hover{background:#353535;color:#fff}
  .umdl-x svg{width:16px;height:16px}
  .umdl-body{
    padding:12px 16px 16px;display:flex;flex-direction:column;gap:10px;
    overflow-y:auto;max-height:calc(84vh - 110px)
  }
  .umdl-body::-webkit-scrollbar{width:6px}
  .umdl-body::-webkit-scrollbar-thumb{background:var(--sg-border-2);border-radius:3px}
  .umdl-opt{
    display:flex;align-items:center;gap:9px;font-size:12px;color:var(--sg-fg-dim);
    padding:10px 12px;background:var(--sg-bg-2);border-radius:8px;border:1px solid var(--sg-border)
  }
  .umdl-opt input[type="checkbox"]{width:16px;height:16px;cursor:pointer;accent-color:#fff;margin:0}
  .umdl-list{display:flex;flex-direction:column;gap:8px}
  .umdl-item{
    background:var(--sg-bg-2);border:1px solid var(--sg-border);border-radius:8px;
    padding:12px 14px;cursor:pointer
  }
  .umdl-item:hover{background:#2d2d2d;border-color:var(--sg-border-2)}
  .umdl-item-top{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:7px}
  .umdl-item .t{font-weight:600;font-size:13px;color:#fff;line-height:1.4;flex:1}
  .umdl-item .s{
    font-size:11px;color:var(--sg-fg-dimmer);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
    font-family:ui-monospace,SF Mono,Consolas,monospace
  }
  .umdl-copy-btn{
    background:var(--sg-bg-3);border:1px solid var(--sg-border-2);color:var(--sg-fg-dim);
    border-radius:6px;padding:7px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0
  }
  .umdl-copy-btn:hover{background:#353535;color:#fff}
  .umdl-copy-btn svg{width:13px;height:13px}
  .umdl-copy-btn.copied{background:#28a745;border-color:#28a745;color:#fff}
  .umdl-empty{padding:32px;color:var(--sg-fg-dimmer);font-size:13px;text-align:center}
  .umdl-toast{
    position:fixed;right:16px;bottom:72px;z-index:2147483646;
    display:flex;flex-direction:column;gap:10px;
    max-width:380px;max-height:70vh;overflow-y:auto;
    align-items:flex-end;
    font:13px system-ui,-apple-system,Segoe UI,Roboto,sans-serif
  }
  .umdl-toast::-webkit-scrollbar{width:5px}
  .umdl-toast::-webkit-scrollbar-thumb{background:var(--sg-border-2);border-radius:3px}
  .umdl-job{
    background:var(--sg-bg);color:var(--sg-fg);border:1px solid var(--sg-border-2);border-radius:10px;
    padding:13px 15px;min-width:280px;display:flex;flex-direction:column
  }
  .umdl-row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}
  .umdl-row .name{
    font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
    max-width:230px;color:#fff
  }
  .umdl-ctrls{display:flex;gap:6px;margin-left:auto}
  .umdl-mini{
    background:var(--sg-bg-3);color:var(--sg-fg-dim);border:1px solid var(--sg-border-2);
    border-radius:7px;padding:6px 8px;cursor:pointer;display:flex;align-items:center;justify-content:center;
    min-width:32px;min-height:32px
  }
  .umdl-mini:hover{background:#353535;color:#fff}
  .umdl-mini svg{width:13px;height:13px}
  .umdl-bar{height:7px;background:var(--sg-bg-2);border-radius:4px;overflow:hidden;border:1px solid var(--sg-border)}
  .umdl-fill{height:7px;width:0;background:#fff}
  .umdl-job.minimized{
    padding:6px;min-width:auto;width:auto;display:inline-flex
  }
  .umdl-job.minimized .umdl-bar,
  .umdl-job.minimized .umdl-row:last-child,
  .umdl-job.minimized .name{display:none!important}
  .umdl-job.minimized .umdl-row:first-child{margin-bottom:0;justify-content:center}
  .umdl-job.minimized .umdl-ctrls{margin:0;gap:0}
  .umdl-job.minimized .umdl-ctrls > :not(.btn-hide){display:none!important}
  .umdl-job.minimized .btn-hide{min-width:32px;min-height:32px;padding:6px}
  .umdl-job.minimized .btn-hide svg{width:14px;height:14px}
  @media (max-width:640px){
    .umdl-fab{right:12px;bottom:12px;width:46px;height:46px}
    .umdl-fab svg{width:15px;height:15px}
    .umdl-toast{left:12px;right:12px;bottom:68px;max-width:none}
    .umdl-card{max-height:90vh;border-radius:10px}
    .umdl-body{max-height:calc(90vh - 100px)}
    .umdl-job.minimized{padding:6px}
    .umdl-job.minimized .btn-hide svg{width:14px;height:14px}
  }
`);

  // SVG Icons
  const ICONS = {
    download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>`,
    close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>`,
    copy: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>`,
    check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>`,
    pause: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`,
    play: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
    cancel: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>`,
    hide: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"/></svg>`,
    show: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 15l7-7 7 7"/></svg>`
  };
  const DL_SVG = ICONS.download;
  const $ = (sel, root = document) => root.querySelector(sel);

  // root UI
  const FAB = document.createElement('button');
  FAB.className = 'umdl-fab'; FAB.innerHTML = DL_SVG; FAB.title = 'Download detected media';
  const BADGE = document.createElement('span'); BADGE.className = 'umdl-badge'; BADGE.style.display = 'none'; FAB.appendChild(BADGE);
  const PICK = document.createElement('div'); PICK.className = 'umdl-pick';
  PICK.innerHTML = `
    <div class="umdl-card">
      <div class="umdl-head">
        <div class="ttl">Select Media</div>
        <button class="umdl-x" title="Close">${ICONS.close}</button>
      </div>
      <div class="umdl-body">
        <label class="umdl-opt"><input type="checkbox" class="umdl-excl"> Exclude small (&lt; 1MB)</label>
        <div class="umdl-list"></div>
      </div>
    </div>`;
  const TOAST = document.createElement('div'); TOAST.className = 'umdl-toast';
  const PANEL = PICK;
  const PROG_WRAP = TOAST;

  function mountUI() {
    if (!document.body) { document.addEventListener('DOMContentLoaded', mountUI, { once: true }); return; }
    if (!FAB.parentNode) document.body.appendChild(FAB);
    if (!PANEL.parentNode) document.body.appendChild(PANEL);
    if (!PROG_WRAP.parentNode) document.body.appendChild(PROG_WRAP);
    try {
      const cardEl = PANEL.querySelector('.umdl-card');
      cardEl?.setAttribute('role', 'dialog');
      cardEl?.setAttribute('aria-modal', 'true');
      const ttlEl = PANEL.querySelector('.ttl');
      if (ttlEl) cardEl?.setAttribute('aria-labelledby', 'sg-ttl');
      if (ttlEl) ttlEl.id = 'sg-ttl';
    } catch { }
  }
  mountUI();

  // Badge updates
  let lastBadgeCount = -1, badgeRaf = 0, badgeWanted = 0;
  function flushBadge() {
    badgeRaf = 0;
    if (badgeWanted > 1) {
      BADGE.textContent = String(badgeWanted);
      BADGE.style.display = 'inline-block';
    } else {
      BADGE.style.display = 'none';
    }
  }
  function setBadge() {
    const n = DB.m3u8.size + DB.vid.size;
    if (n === lastBadgeCount) return;
    lastBadgeCount = n;
    badgeWanted = n;
    if (!badgeRaf) badgeRaf = requestAnimationFrame(flushBadge);
  }

  let idleT;
  function setIdle() { clearTimeout(idleT); idleT = setTimeout(() => FAB.classList.add('idle'), CFG.UI_IDLE_MS); }
  function clearIdle() { FAB.classList.remove('idle'); clearTimeout(idleT); }
  function showFab() { mountUI(); FAB.classList.add('show'); setBadge(); clearIdle(); setIdle(); }
  function closePanel() { PANEL.classList.remove('show'); }
  function setFabBusy(b) {
    if (b) { FAB.classList.add('busy'); FAB.disabled = true; }
    else { FAB.classList.remove('busy'); FAB.disabled = false; }
  }
  FAB.addEventListener('mouseenter', clearIdle);
  FAB.addEventListener('mouseleave', setIdle);

  // Copy to clipboard helper
  async function copyToClipboard(text, btn) {
    try {
      await navigator.clipboard.writeText(text);
      const originalHTML = btn.innerHTML;
      btn.innerHTML = ICONS.check;
      btn.classList.add('copied');
      setTimeout(() => {
        btn.innerHTML = originalHTML;
        btn.classList.remove('copied');
      }, 1500);
      return true;
    } catch (e) {
      const textarea = document.createElement('textarea');
      textarea.value = text;
      textarea.style.position = 'fixed';
      textarea.style.opacity = '0';
      document.body.appendChild(textarea);
      textarea.select();
      try {
        document.execCommand('copy');
        const originalHTML = btn.innerHTML;
        btn.innerHTML = ICONS.check;
        btn.classList.add('copied');
        setTimeout(() => {
          btn.innerHTML = originalHTML;
          btn.classList.remove('copied');
        }, 1500);
        return true;
      } catch (err) {
        console.error('Copy failed:', err);
        return false;
      } finally {
        document.body.removeChild(textarea);
      }
    }
  }

  // =========================
  // Detection
  // =========================
  function take(url) {
    try {
      if (!url || (!isHttp(url) && !isBlob(url))) return;
      let changed = false;
      if (isM3U8Url(url) || (isBlob(url) && BLOBS.get(url)?.kind === 'm3u8')) {
        if (boundedAdd(DB.m3u8, url)) { showFab(); changed = true; }
      } else if (isVideoUrl(url) || (isBlob(url) && BLOBS.get(url)?.kind === 'video')) {
        if (boundedAdd(DB.vid, url)) { showFab(); changed = true; }
      }
      if (changed) setBadge();
    } catch { }
  }
  // Hook: createObjectURL
  (() => {
    const bak = URL.createObjectURL;
    URL.createObjectURL = function (obj) {
      const href = bak.call(this, obj);
      try {
        const now = Date.now();
        if (obj instanceof Blob) {
          const type = obj.type || '';
          const info = { blob: obj, type, size: obj.size, kind: 'other', ts: now };
          if (looksM3U8Type(type)) { info.kind = 'm3u8'; BLOBS.set(href, info); take(href); }
          else if (looksVideoType(type)) { info.kind = 'video'; BLOBS.set(href, info); take(href); }
          else {
            const need = /octet-stream|text\/plain|^$/.test(type);
            if (need && obj.size > 0) {
              obj.slice(0, Math.min(2048, obj.size)).text().then(t => {
                if (/^#EXTM3U/i.test(t)) info.kind = 'm3u8';
                else info.kind = 'other';
                BLOBS.set(href, info);
                take(href);
              }).catch(() => BLOBS.set(href, info));
            } else BLOBS.set(href, info);
          }
        } else BLOBS.set(href, { blob: null, type: 'other', size: 0, kind: 'other', ts: now });
      } catch (e) { err('createObjectURL', e); }
      return href;
    };
    const r = URL.revokeObjectURL;
    URL.revokeObjectURL = function (href) {
      try {
        const info = BLOBS.get(href);
        if (info) { info.revoked = true; info.ts = Date.now(); }
      } catch { }
      return r.call(this, href);
    };
  })();
  // Hook: fetch
  (() => {
    const f = window.fetch;
    if (typeof f === 'function') {
      window.fetch = function (...args) {
        try {
          const u = typeof args[0] === 'string' ? args[0] : args[0]?.url;
          take(u);
        } catch { }
        return f.apply(this, args);
      };
    }
  })();
  // Hook: XHR
  (() => {
    const o = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (method, url, ...rest) {
      try { take(url); } catch { }
      return o.call(this, method, url, ...rest);
    };
  })();
  // PerfObserver
  try {
    const po = new PerformanceObserver(list => list.getEntries().forEach(e => take(e.name)));
    po.observe({ entryTypes: ['resource'] });
  } catch { }
  // Video tags scanning
  function watchVideo(v) {
    if (v.__sg_watch) return;
    v.__sg_watch = true;
    const cb = () => {
      const srcs = [v.currentSrc || v.src, ...Array.from(v.querySelectorAll('source')).map(s => s.src)];
      srcs.forEach(take);
    };
    ['loadstart', 'loadedmetadata', 'canplay'].forEach(ev => v.addEventListener(ev, cb));
    watchedVideos.add(v);
    cb();
  }
  function scanVideos() {
    document.querySelectorAll('video').forEach(watchVideo);
  }
  let mo;
  document.addEventListener('DOMContentLoaded', () => {
    scanVideos();
    mo = new MutationObserver((mutations) => {
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          if (!(node instanceof Element)) continue;
          if (node.tagName === 'VIDEO') {
            watchVideo(node);
          } else {
            node.querySelectorAll?.('video')?.forEach(watchVideo);
          }
        }
      }
      for (const v of Array.from(watchedVideos)) if (!v.isConnected) watchedVideos.delete(v);
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  });

  // =========================
  // Picker helpers
  // =========================
  function renderList(list) {
    const listEl = PANEL.querySelector('.umdl-list');
    listEl.innerHTML = '';
    if (!list.length) {
      const empty = document.createElement('div');
      empty.className = 'umdl-empty';
      empty.textContent = 'No items match the filter.';
      listEl.appendChild(empty);
      return;
    }
    list.forEach((it) => {
      const div = document.createElement('div');
      div.className = 'umdl-item';
      div.setAttribute('role', 'button');
      div.tabIndex = 0;
      const shortUrl = it.url.length > 80 ? it.url.slice(0, 80) + '…' : it.url;
      div.innerHTML = `
      <div class="umdl-item-top">
        <div class="t">${escapeHtml(it.label)}</div>
        <button class="umdl-copy-btn" title="Copy URL">${ICONS.copy}</button>
      </div>
      <div class="s" title="${escapeHtml(it.url)}">${escapeHtml(shortUrl)}</div>
    `;

      const copyBtn = div.querySelector('.umdl-copy-btn');
      copyBtn.onclick = (e) => {
        e.stopPropagation();
        copyToClipboard(it.url, copyBtn);
      };

      const act = () => resolvePicker(it);
      div.onclick = (e) => {
        if (!e.target.closest('.umdl-copy-btn')) {
          act();
        }
      };
      div.onkeydown = (e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          act();
        }
      };

      listEl.appendChild(div);
    });
  }
  let resolvePicker = () => { };
  async function pickFromList(items, { title = 'Select Media', filterable = true } = {}) {
    return new Promise((resolve) => {
      resolvePicker = (v) => { closePanel(); resolve(v ?? null); };
      const ttl = PANEL.querySelector('.ttl');
      const exWrap = PANEL.querySelector('.umdl-opt');
      const ex = PANEL.querySelector('.umdl-excl');
      const x = PANEL.querySelector('.umdl-x');

      ttl.textContent = title;
      if (filterable) {
        const anySizeKnown = items.some(i => i.size != null);
        exWrap.style.display = anySizeKnown ? 'flex' : 'none';
        ex.checked = SETTINGS.excludeSmall;
        const apply = () => {
          const listToUse = (SETTINGS.excludeSmall && anySizeKnown)
            ? items.filter(x => x.size == null || x.size >= CFG.SMALL_BYTES)
            : items;
          renderList(listToUse);
        };
        ex.onchange = () => { setExcludeSmall(ex.checked); apply(); };
        apply();
      } else {
        exWrap.style.display = 'none';
        renderList(items);
      }
      x.onclick = () => resolvePicker(null);
      PANEL.onclick = (e) => { if (e.target === PANEL) resolvePicker(null); };
      PANEL.classList.add('show');
    });
  }

  // =========================
  // UI interactions
  // =========================
  FAB.addEventListener('click', async (ev) => {
    clearIdle(); setIdle();
    let items = [];
    setFabBusy(true);
    try {
      items = await buildItems();

      // Alt-click: quick start when exactly 1 item
      if (ev.altKey && items.length === 1) {
        await handleItem(items[0]);
        return;
      }

      const sel = await pickFromList(items, { title: 'Select Media', filterable: true });
      if (!sel) return;
      await handleItem(sel);
    } catch (e) {
      alert(e?.message || String(e));
    } finally {
      setFabBusy(false);
    }
  });

  // Progress card
  function makeProgress(title, src, { stoppable = false, onStop, onCancel, segs = 0 } = {}) {
    const div = document.createElement('div');
    div.className = 'umdl-job';
    div.innerHTML = `
    <div class="umdl-row">
      <div class="name" title="${escapeHtml(src)}">${escapeHtml(title)}</div>
      <div class="umdl-ctrls">
        ${stoppable ? `<button class="umdl-mini btn-stop" title="Pause">${ICONS.pause}</button>` : ''}
        <button class="umdl-mini btn-hide" title="Hide">${ICONS.hide}</button>
        <button class="umdl-mini btn-x" title="Cancel">${ICONS.cancel}</button>
      </div>
    </div>
    <div class="umdl-bar"><div class="umdl-fill"></div></div>
    <div class="umdl-row" style="margin-top:6px;font-size:11px"><span class="status" style="color:#999">${segs ? `${segs} segs` : ''}</span><span class="pct">0%</span></div>
  `;
    PROG_WRAP.appendChild(div);
    const fill = div.querySelector('.umdl-fill');
    const pct = div.querySelector('.pct');
    const btnX = div.querySelector('.btn-x');
    const btnStop = div.querySelector('.btn-stop');
    const btnHide = div.querySelector('.btn-hide');

    btnX.onclick = () => onCancel?.();

    if (btnStop) {
      btnStop.onclick = () => {
        const v = onStop?.();
        if (v === 'paused') {
          btnStop.innerHTML = ICONS.play;
          btnStop.title = 'Resume';
        } else if (v === 'resumed') {
          btnStop.innerHTML = ICONS.pause;
          btnStop.title = 'Pause';
        }
      };
    }

    btnHide.onclick = () => {
      const isMinimized = div.classList.toggle('minimized');
      btnHide.innerHTML = isMinimized ? ICONS.show : ICONS.hide;
      btnHide.title = isMinimized ? 'Show' : 'Hide';
    };

    return {
      update(p, txt = '') {
        const pc = Math.max(0, Math.min(100, Math.floor(p)));
        fill.style.width = pc + '%';
        pct.textContent = `${pc}%${txt ? ' ' + txt : ''}`;
      },
      done(ok = true, msg) {
        fill.style.background = ok ? '#10b981' : '#e74c3c';
        this.update(100, msg || (ok ? '✓' : '✗'));
        setTimeout(() => div.remove(), 2200);
      },
      remove() { div.remove(); }
    };
  }

  // =========================
  // M3U8 parsing via m3u8-parser
  // =========================
  const M3U8 = (typeof m3u8Parser !== 'undefined' ? m3u8Parser : (window.m3u8Parser || globalThis.m3u8Parser));
  function parseManifest(text) {
    if (!M3U8?.Parser) throw new Error('m3u8-parser not available');
    const parser = new M3U8.Parser();
    parser.push(text);
    parser.end();
    return parser.manifest;
  }
  function buildVariantsFromManifest(man, base) {
    const out = [];
    const pls = Array.isArray(man.playlists) ? man.playlists : [];
    for (const p of pls) {
      if (!p?.uri) continue;
      const a = p.attributes || {};
      const w = a.RESOLUTION?.width ?? null;
      const h = a.RESOLUTION?.height ?? null;
      const res = (w && h) ? `${w}x${h}` : null;
      out.push({
        url: safeAbs(p.uri, base),
        res, w, h,
        peak: a.BANDWIDTH != null ? parseInt(a.BANDWIDTH, 10) : null,
        avg: a['AVERAGE-BANDWIDTH'] != null ? parseInt(a['AVERAGE-BANDWIDTH'], 10) : null,
        codecs: a.CODECS || null,
      });
    }
    return out;
  }
  function rangeHeaderFromByterange(br, fallbackStart = 0) {
    if (!br || typeof br.length !== 'number') return { header: null, next: fallbackStart };
    const start = (typeof br.offset === 'number') ? br.offset : fallbackStart;
    const end = start + br.length - 1;
    return { header: `bytes=${start}-${end}`, next: end + 1 };
  }
  function buildMediaFromManifest(man, base) {
    const segs = [];
    const srcSegs = Array.isArray(man.segments) ? man.segments : [];
    let lastNext = 0;
    let prevMapSig = null;
    for (let i = 0; i < srcSegs.length; i++) {
      const s = srcSegs[i];

      // Segment byterange -> Range header
      let rangeHeader = null;
      if (s.byterange) {
        const r = rangeHeaderFromByterange(s.byterange, lastNext);
        rangeHeader = r.header;
        lastNext = r.next;
      } else {
        lastNext = 0;
      }

      // Init map (fMP4)
      let map = null;
      let needMap = false;
      if (s.map?.uri) {
        const mapUri = safeAbs(s.map.uri, base);
        let mRange = null;
        if (s.map.byterange) {
          const mr = rangeHeaderFromByterange(s.map.byterange, 0);
          mRange = mr.header;
        }
        map = { uri: mapUri, rangeHeader: mRange };
        const sig = `${mapUri}|${mRange || ''}`;
        needMap = (sig !== prevMapSig);
        if (needMap) prevMapSig = sig;
      }

      // Key
      let key = null;
      if (s.key?.method && s.key.method !== 'NONE') {
        key = {
          method: String(s.key.method).toUpperCase(),
          uri: s.key.uri ? safeAbs(s.key.uri, base) : null,
          iv: s.key.iv || null
        };
      }

      segs.push({
        uri: safeAbs(s.uri, base),
        dur: s.duration || 0,
        range: rangeHeader,
        key,
        map,
        needMap
      });
    }
    return { segs, mediaSeq: man.mediaSequence || 0, endList: !!man.endList };
  }
  function computeExactBytesFromSegments(parsed) {
    let exact = true;
    let total = 0;
    const seenInit = new Set();
    for (const s of parsed.segs) {
      if (s.range) {
        const r = parseRange(s.range);
        if (!r || r.end == null) { exact = false; } else { total += (r.end - r.start + 1); }
      } else exact = false;

      if (s.needMap && s.map) {
        if (s.map.rangeHeader) {
          const key = `${s.map.uri}|${s.map.rangeHeader}`;
          if (!seenInit.has(key)) {
            seenInit.add(key);
            const mr = parseRange(s.map.rangeHeader);
            if (!mr || mr.end == null) exact = false;
            else total += (mr.end - mr.start + 1);
          }
        } else exact = false;
      }
    }
    return exact ? total : null;
  }
  function estimateHlsFromManifest(man, base, variant = null) {
    const parsed = buildMediaFromManifest(man, base);
    const seconds = (Array.isArray(man.segments) ? man.segments : []).reduce((a, s) => a + (s.duration || 0), 0);
    const vod = !!man.endList;
    const brBytes = computeExactBytesFromSegments(parsed);
    if (brBytes != null) return { bytes: brBytes, seconds, vod, via: 'byterange' };
    const bw = variant?.avg ?? variant?.peak ?? null;
    if (vod && bw && seconds > 0) return { bytes: Math.round((bw / 8) * seconds), seconds, vod, via: 'avg-bw' };
    return { bytes: null, seconds, vod, via: 'unknown' };
  }

  // =========================
  // Build items
  // =========================
  async function buildItems() {
    const out = [];
    // m3u8 sources
    for (const u of DB.m3u8) {
      const info = BLOBS.get(u);
      try {
        const mtxt = await getText(u);
        const man = parseManifest(mtxt);
        if (Array.isArray(man.playlists) && man.playlists.length > 0) {
          out.push({ kind: 'hls', url: u, label: 'HLS', size: null });
        } else if (Array.isArray(man.segments) && man.segments.length > 0) {
          const est = estimateHlsFromManifest(man, u, null);
          const size = est.bytes ?? null;
          const label = `HLS${size ? ' • ~' + fmtBytes(size) : ''}`;
          out.push({ kind: 'hls', url: u, label, size });
        } else {
          out.push({ kind: 'hls', url: u, label: 'HLS', size: info?.size ?? null });
        }
      } catch {
        out.push({ kind: 'hls', url: u, label: 'HLS', size: info?.size ?? null });
      }
    }
    // direct videos
    for (const u of DB.vid) {
      const info = BLOBS.get(u);
      const ext = guessExt(u, info?.type).toUpperCase();
      const size = info?.size ?? null;
      out.push({ kind: 'video', url: u, label: `${ext}${size ? ' • ' + fmtBytes(size) : ''}`, size });
    }
    return out;
  }
  async function handleItem(it) {
    if (it.kind === 'video') return downloadDirect(it.url);
    if (it.kind === 'variant') return downloadHls(it.url, it.variant);
    if (it.kind === 'hls') return downloadHls(it.url);
  }

  // =========================
  // Direct video download (FileSaver)
  // =========================
  async function downloadDirect(url) {
    log('Direct:', url);
    const info = BLOBS.get(url);
    const ext = guessExt(url, info?.type);
    const fn = `${cleanName(document.title)}.${ext}`;

    // blob case
    if (info?.blob) {
      const card = makeProgress(fn, url, { onCancel: () => card.remove() });
      try {
        window.saveAs(info.blob, fn);
        card.update(100, ''); card.done(true);
      } catch (e) {
        card.done(false, e?.message);
      }
      return;
    }

    let total = 0, req = null, cancelled = false;
    const card = makeProgress(fn, url, { onCancel: () => { cancelled = true; try { req?.abort?.(); } catch { }; card.remove(); } });
    try {
      const meta = await headMeta(url);
      total = meta.length || 0;
      req = gmGet({
        url, responseType: 'arraybuffer', timeout: CFG.REQ_MS,
        onprogress: (e) => {
          if (cancelled) return;
          const loaded = e?.loaded || 0;
          if (total > 0) card.update((loaded / total) * 100, `${fmtBytes(loaded)}/${fmtBytes(total)}`);
          else card.update(0, `${fmtBytes(loaded)}`);
        }
      });
      const buf = await req; if (cancelled) return;
      const blob = new Blob([buf], { type: meta.type || `video/${ext}` });
      window.saveAs(blob, fn);
      card.update(100, ''); card.done(true);
    } catch (e) {
      card.done(false, e?.message || 'Failed');
    }
  }

  // =========================
  // File writer (stream to disk when supported)
  // =========================
  async function makeFileWriter(suggestedName, mime) {
    if (typeof window.showSaveFilePicker === 'function') {
      try {
        const handle = await window.showSaveFilePicker({ suggestedName });
        const stream = await handle.createWritable();
        return {
          write: (chunk) => stream.write(chunk),
          close: () => stream.close(),
          abort: () => stream.abort?.()
        };
      } catch {
        // fallthrough to in-memory
      }
    }
    // Fallback: in-memory (FileSaver)
    const chunks = [];
    return {
      write: (chunk) => { chunks.push(chunk); return Promise.resolve(); },
      close: () => {
        const blob = new Blob(chunks, { type: mime });
        window.saveAs(blob, suggestedName);
      },
      abort: () => { chunks.length = 0; }
    };
  }

  // =========================
  // HLS download (via m3u8-parser, streamed writer)
  // =========================
  async function downloadHls(url, preVariant = null) {
    log('HLS:', url);
    const txt = await getText(url);
    const man = parseManifest(txt);

    let mediaUrl = url, chosenVariant = preVariant;

    // Master playlist: prompt for variant
    if (Array.isArray(man.playlists) && man.playlists.length > 0) {
      const variants = buildVariantsFromManifest(man, url).sort((a, b) => (b.h || 0) - (a.h || 0) || (b.avg || b.peak || 0) - (a.avg || a.peak || 0));
      if (!variants.length) throw new Error('No variants found');

      const items = [];
      for (const v of variants) {
        let label = [v.res, (v.avg || v.peak) ? `${Math.round((v.avg || v.peak) / 1000)}k` : null].filter(Boolean).join(' • ') || 'Variant';
        let size = null;
        try {
          const mediaTxt = await getText(v.url);
          const vMan = parseManifest(mediaTxt);
          const est = estimateHlsFromManifest(vMan, v.url, v);
          if (est.bytes != null) size = est.bytes;
          if (size != null) label += ` • ~${fmtBytes(size)}`;
        } catch { }
        items.push({ kind: 'variant', url: v.url, label, variant: v, size });
      }

      const selected = await pickFromList(items, { title: 'Select Quality', filterable: true });
      if (!selected) return;
      chosenVariant = selected.variant; mediaUrl = selected.url;
    }

    // Media playlist
    const mediaTxt = await getText(mediaUrl);
    const mediaMan = parseManifest(mediaTxt);
    if (!Array.isArray(mediaMan.segments) || mediaMan.segments.length === 0) throw new Error('Invalid playlist');
    const parsed = buildMediaFromManifest(mediaMan, mediaUrl);
    if (!parsed.segs.length) throw new Error('No segments');

    const isFmp4 = parsed.segs.some(s => s.map) || /\.m4s(\?|$)/i.test(parsed.segs[0].uri);
    const ext = isFmp4 ? 'mp4' : 'ts';
    const name = cleanName(document.title);
    const q = chosenVariant?.res ? `_${chosenVariant.res}` : '';
    const filename = `${name}${q}.${ext}`;
    await downloadSegments(parsed, filename, ext, isFmp4, url);
  }

  async function downloadSegments(parsed, filename, ext, isFmp4, srcUrl) {
    const segs = parsed.segs;
    const total = segs.length;
    let paused = false, canceled = false, ended = false;
    const attempts = new Uint8Array(total);
    const status = new Int8Array(total); // 0=queued,1=loading,2=done,-1=failed
    const inflight = new Map(); // idx -> req
    const inprog = new Map(); // idx -> {loaded,total}
    const buffers = new Map(); // idx -> Uint8Array (ready for ordered write)
    let done = 0, active = 0, nextIdx = 0, writePtr = 0, byteDone = 0, avgLen = 0;

    const retryQ = new Set();
    const enqueueRetry = (i) => { retryQ.add(i); };
    const takeRetry = () => {
      const it = retryQ.values().next();
      if (it.done) return -1;
      const v = it.value;
      retryQ.delete(v);
      return v;
    };

    const mime = isFmp4 ? 'video/mp4' : 'video/mp2t';
    const writer = await makeFileWriter(filename, mime);

    const card = makeProgress(filename, srcUrl, {
      stoppable: true,
      segs: total,
      onStop() { paused = !paused; if (!paused) pump(); return paused ? 'paused' : 'resumed'; },
      onCancel() {
        canceled = true;
        abortAll();
        try { writer.abort?.(); } catch { }
        card.remove();
      }
    });

    // caches for keys/maps
    const keyCache = new Map(), keyInflight = new Map();
    const mapCache = new Map(), mapInflight = new Map();
    const onceKey = (k, fn) => once(keyCache, keyInflight, k, fn);
    const onceMap = (k, fn) => once(mapCache, mapInflight, k, fn);

    const draw = (() => {
      let raf = 0;
      return () => {
        if (raf) return;
        raf = requestAnimationFrame(() => {
          raf = 0;
          let partial = 0;
          inprog.forEach(({ loaded, total }) => {
            if (total > 0) partial += Math.min(1, loaded / total);
            else if (avgLen > 0) partial += Math.min(1, loaded / avgLen);
          });
          const pct = ((done + partial) / total) * 100;
          card.update(pct, `${done}/${total}`);
        });
      };
    })();

    function abortAll() {
      for (const [, r] of inflight) { try { r.abort?.(); } catch { } }
      inflight.clear(); inprog.clear();
    }
    function maybeFailFast(i) {
      if (status[i] === -1 && i === writePtr && !ended) {
        abortAll();
        finalize(false);
      }
    }
    function fail(i, why) {
      const a = ++attempts[i];
      if (a > CFG.RETRIES) {
        status[i] = -1; err(`Segment ${i} failed: ${why}`);
        maybeFailFast(i);
      } else {
        status[i] = 0;
        enqueueRetry(i);
      }
    }
    async function fetchKeyBytes(s) {
      if (!s.key || s.key.method !== 'AES-128' || !s.key.uri) return null;
      return onceKey(s.key.uri, async () => new Uint8Array(await getBin(s.key.uri)));
    }
    async function fetchMapBytes(s) {
      if (!s.needMap || !s.map?.uri) return null;
      const id = `${s.map.uri}|${s.map.rangeHeader || ''}`;
      return onceMap(id, async () => {
        const headers = s.map.rangeHeader ? { Range: s.map.rangeHeader } : {};
        return new Uint8Array(await getBin(s.map.uri, headers));
      });
    }

    let writing = Promise.resolve();
    function queueFlush() {
      writing = writing.then(async () => {
        while (buffers.has(writePtr)) {
          const chunk = buffers.get(writePtr);
          buffers.delete(writePtr);
          await writer.write(chunk);
          writePtr++;
        }
      });
    }

    async function handleSeg(i) {
      const s = segs[i];
      status[i] = 1; active++;
      if (s.key && s.key.method && s.key.method !== 'AES-128') {
        active--; status[i] = -1; err('Unsupported key method', s.key.method); maybeFailFast(i); check(); return;
      }
      const headers = s.range ? { Range: s.range } : {};
      const req = gmGet({
        url: s.uri, responseType: 'arraybuffer', headers, timeout: CFG.REQ_MS,
        onprogress: (e) => { inprog.set(i, { loaded: e?.loaded || 0, total: e?.total || 0 }); draw(); }
      });
      inflight.set(i, req);
      let buf;
      try {
        buf = await req;
        // decrypt?
        const kb = await fetchKeyBytes(s);
        if (kb) {
          const iv = s.key.iv ? hexToU8(s.key.iv) : ivFromSeq(parsed.mediaSeq + i);
          buf = await aesCbcDec(buf, kb, iv);
        }
        let u8 = new Uint8Array(buf);
        // prepend init map?
        if (s.needMap) {
          const mapBytes = await fetchMapBytes(s);
          if (mapBytes?.length) {
            const join = new Uint8Array(mapBytes.length + u8.length);
            join.set(mapBytes, 0); join.set(u8, mapBytes.length);
            u8 = join;
          }
        }
        buffers.set(i, u8);
        inprog.delete(i);
        inflight.delete(i);
        status[i] = 2; active--; done++; byteDone += u8.length; avgLen = byteDone / Math.max(1, done);
        draw();
        queueFlush();
      } catch (e) {
        inprog.delete(i);
        inflight.delete(i);
        active--;
        fail(i, e?.message || 'net/decrypt');
      } finally {
        pump();
        check();
      }
    }

    function pump() {
      if (paused || canceled || ended) return;
      while (active < CFG.CONC) {
        let idx = takeRetry();
        if (idx === -1) {
          while (nextIdx < total && status[nextIdx] !== 0) nextIdx++;
          if (nextIdx < total) idx = nextIdx++;
        }
        if (idx === -1) break;
        handleSeg(idx);
      }
    }
    function check() {
      if (ended) return;
      if (status[writePtr] === -1) {
        abortAll();
        return finalize(false);
      }
      if (done === total) return finalize(true);
      if (!active && Array.prototype.some.call(status, v => v === -1)) return finalize(false);
    }
    async function finalize(ok) {
      if (ended) return; ended = true;
      try {
        queueFlush();
        await writing;
        if (ok) {
          await writer.close();
          card.update(100, ''); card.done(true);
        } else {
          try { await writer.abort?.(); } catch { }
          card.done(false);
        }
      } catch (e) {
        err('finalize', e);
        card.done(false);
      } finally {
        for (const [, r] of inflight) { try { r.abort?.(); } catch { } }
        inflight.clear(); inprog.clear();
      }
    }
    pump();
  }

  // =========================
  // Escape helper (UI)
  // =========================
  const _escapeDiv = document.createElement('div');
  function escapeHtml(x) { _escapeDiv.textContent = x == null ? '' : String(x); return _escapeDiv.innerHTML; }

  // startup
  mountUI();
})();