☰

xHamster Downloader

Single + batch downloads with per-video quality and format (MP4, or MKV with subtitles, metadata and cover art), resume and auto cleanup.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         xHamster Downloader
// @namespace    xh-dl
// @version      5.2.0
// @description  Single + batch downloads with per-video quality and format (MP4, or MKV with subtitles, metadata and cover art), resume and auto cleanup.
// @license      GPLv2
// @match        https://xhamster.com/*
// @match        https://*.xhamster.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(() => {
  'use strict';

  const log = (...a) => console.info('%c[XH-DL]', 'color:#f90;font-weight:bold', ...a);

  // ======================= tunables =======================
  const PREFER_CODEC = 'h264';                  // tie-breaker between streams of the same resolution
  const CONCURRENCY = 4;                        // parallel segment fetches per video
  const SAVED_TTL_MS = 10 * 60 * 1000;          // safety net for finished temp files left behind (tab closed early)
  const STALE_MS = 7 * 24 * 60 * 60 * 1000;     // unfinished (resumable) downloads untouched this long are removed
  const INFO_TTL_MS = 20 * 60 * 1000;           // stream links expire; re-read a video page older than this
  const PREPARE_AT = 0.95;                      // next video locks and shows "Preparing…" at this progress
  const SUB_LANGS = ['en'];                     // MKV subtitle tracks: original language + these

  // ======================= settings (stored in this site's localStorage) =======================
  const QUALITY_OPTIONS = [
    ['highest', 'Highest available'], ['2160p', '2160p (4K)'], ['1440p', '1440p'], ['1080p', '1080p'],
    ['720p', '720p'], ['480p', '480p'], ['240p', '240p'], ['144p', '144p'],
  ];
  const DEFAULTS = { quality: 'highest', format: 'mkv' };
  const SETTINGS_KEY = 'xhdl:settings';
  function loadSettings() {
    try { return { ...DEFAULTS, ...(JSON.parse(localStorage.getItem(SETTINGS_KEY) || 'null') || {}) }; } catch { return { ...DEFAULTS }; }
  }
  function saveSettings(s) {
    try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch (e) { log('could not save settings', e); }
  }
  let settings = loadSettings();

  // ======================= 1. capture playlist URLs the player requests =======================
  const seen = new Set();
  const note = (u) => {
    try {
      const s = String(u);
      if (/\.m3u8(\?|$)/i.test(s)) seen.add(new URL(s, location.href).href);
    } catch {}
  };
  const IS_VIDEO_PAGE = /^\/videos\//.test(location.pathname);
  const nativeFetch = window.fetch.bind(window);
  // Only video pages need the playlist capture; list pages (home, search) are left untouched.
  if (IS_VIDEO_PAGE) {
    window.fetch = function (input) {
      note(typeof input === 'string' ? input : input && input.url);
      return nativeFetch.apply(this, arguments);
    };
    const nativeOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (method, url) {
      note(url);
      return nativeOpen.apply(this, arguments);
    };
  }

  // ======================= 2. helpers =======================
  const abortErr = () => new DOMException('Cancelled', 'AbortError');
  const sleep = (ms, signal) => new Promise((res, rej) => {
    const t = setTimeout(res, ms);
    signal?.addEventListener('abort', () => { clearTimeout(t); rej(abortErr()); }, { once: true });
  });
  const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  const fmtSize = (b) => (!b ? '—' : b < 1024 ** 3 ? `${Math.max(1, Math.round(b / 1048576))} MB` : `${(b / 1024 ** 3).toFixed(2)} GB`);

  async function getText(u, signal) {
    const r = await nativeFetch(u, { credentials: 'omit', signal });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return r.text();
  }

  async function fetchBuffer(url, signal) {
    for (let attempt = 0; ; attempt++) {
      try {
        const r = await nativeFetch(url, { credentials: 'omit', signal });
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return await r.arrayBuffer();
      } catch (e) {
        if (e.name === 'AbortError' || attempt >= 3) throw e;
        await sleep(800 * (attempt + 1), signal);
      }
    }
  }

  function parseMaster(txt, base) {
    const lines = txt.split(/\r?\n/);
    const out = [];
    for (let i = 0; i < lines.length; i++) {
      const l = lines[i];
      if (!l.startsWith('#EXT-X-STREAM-INF')) continue;
      const bw = +((/[:,]BANDWIDTH=(\d+)/.exec(l) || [])[1] || 0);
      const avg = +((/AVERAGE-BANDWIDTH=(\d+)/.exec(l) || [])[1] || 0);
      const res = /RESOLUTION=(\d+)x(\d+)/.exec(l);
      const name = /NAME="([^"]+)"/.exec(l);
      let j = i + 1;
      while (j < lines.length && (!lines[j].trim() || lines[j].startsWith('#'))) j++;
      if (lines[j]) {
        const h = res ? +res[2] : +((/(\d{3,4})p/.exec(name?.[1] || '') || [])[1] || 0);
        out.push({ url: new URL(lines[j].trim(), base).href, bw, avg, h, label: name ? name[1] : h ? `${h}p` : 'best' });
      }
    }
    return out;
  }

  function parseMedia(txt, base) {
    let init = null;
    let encrypted = false;
    const segs = [];
    for (const raw of txt.split(/\r?\n/)) {
      const l = raw.trim();
      if (!l) continue;
      if (l.startsWith('#EXT-X-MAP')) {
        const m = /URI="([^"]+)"/.exec(l);
        if (m) init = new URL(m[1], base).href;
      } else if (l.startsWith('#EXT-X-KEY') && !/METHOD=NONE/i.test(l)) {
        encrypted = true;
      } else if (!l.startsWith('#')) {
        segs.push(new URL(l, base).href);
      }
    }
    return { init, segs, encrypted };
  }

  const codecScore = (url) => (url.includes(`.${PREFER_CODEC}.`) ? 1 : 0);

  async function getVariants(candidates, signal) {
    const uniq = [...new Set(candidates)];
    if (!uniq.length) throw new Error('No stream playlist found. Start the video for a second, then retry.');
    const all = [];
    for (const url of uniq) {
      try {
        const txt = await getText(url, signal);
        if (txt.includes('#EXT-X-STREAM-INF')) {
          all.push(...parseMaster(txt, url));
        } else if (txt.includes('#EXTINF')) {
          const h = +((/(\d{3,4})p/.exec(url) || [])[1] || 0);
          all.push({ url, bw: 0, avg: 0, h, label: h ? `${h}p` : 'best', mediaText: txt });
        }
      } catch (e) {
        if (e.name === 'AbortError') throw e;
        log('skipping playlist', url, e.message);
      }
    }
    if (!all.length) throw new Error('Could not read any stream playlist.');
    all.sort((a, b) => b.h - a.h || codecScore(b.url) - codecScore(a.url) || b.bw - a.bw);
    const labels = new Set();
    return all.filter((v) => !labels.has(v.label) && labels.add(v.label));
  }

  function chooseVariant(variants, pref) {
    if (!variants?.length) throw new Error('No qualities available.');
    if (!pref || pref === 'highest') return variants[0];
    const exact = variants.find((v) => v.label === pref);
    if (exact) return exact;
    const want = parseInt(pref, 10) || 0;
    return variants.find((v) => v.h && v.h <= want) || variants[variants.length - 1];
  }

  // Estimated file size from the stream's bitrate and the video's duration.
  function estSize(variants, label, duration) {
    const v = variants?.find((x) => x.label === label);
    const bw = v ? v.avg || v.bw : 0;
    return bw && duration ? Math.round((bw * duration) / 8) : 0;
  }

  const cleanName = (t) =>
    (t || 'video').replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '').replace(/\s+/g, ' ').trim().slice(0, 120) || 'video';

  function vidFromUrl(u) {
    const m = /-(xh[A-Za-z0-9]+)(?:[/?#]|$)/.exec(u);
    if (m) return m[1];
    try { return new URL(u, location.href).pathname.split('/').filter(Boolean).pop(); } catch { return 'video'; }
  }

  let busyCount = 0;
  const onBeforeUnload = (e) => { e.preventDefault(); e.returnValue = ''; };
  function guardUnload(delta) {
    busyCount = Math.max(0, busyCount + delta);
    if (busyCount) window.addEventListener('beforeunload', onBeforeUnload);
    else window.removeEventListener('beforeunload', onBeforeUnload);
  }

  // ======================= 3. video page info =======================
  function parseInitials(html) {
    const m = /window\.initials\s*=\s*(\{[\s\S]*?\});\s*<\/script>/.exec(html);
    if (!m) return null;
    try { return JSON.parse(m[1]); } catch { return null; }
  }

  function metaFromInitials(init, url) {
    const vm = init?.videoModel || {};
    const ve = init?.videoEntity || {};
    const rm = init?.ratingComponent?.ratingModel || ve.rating || {};
    const tagList = init?.videoTagsComponent?.tags || [];
    const names = (f) => tagList.filter(f).map((t) => t.name).filter(Boolean);
    const likes = +rm.likes || 0;
    const dislikes = +rm.dislikes || 0;
    const people = [...new Set([
      ...names((t) => t.isPornstar || t.isCreator || t.isCeleb),
      ...(ve.pornstarModels || []).map((p) => p.nameNormalized || p.name),
    ].filter(Boolean))];
    // Subtitles live under the player *plugin* settings on this site.
    const subsSrc = init?.xplayerPluginSettings?.subtitles?.tracks || init?.xplayerSettings?.subtitles?.tracks || [];
    return {
      id: vm.id || ve.id || '',
      idHash: vm.idHash || ve.idHashSlug || '',
      title: vm.title || ve.title || '',
      uploader: vm.author?.name || '',
      uploaderUrl: vm.author?.pageURL || '',
      creator: vm.landing?.name || '',
      creatorUrl: vm.landing?.link || '',
      created: vm.created || 0,
      duration: vm.duration || ve.duration || 0,
      description: vm.description || ve.description || '',
      categories: names((t) => t.isCategory),
      tags: names((t) => t.isTag),
      channels: names((t) => t.isChannel || t.isBrand),
      people,
      views: vm.views ?? ve.views ?? null,
      comments: vm.comments ?? ve.commentsCount ?? null,
      likes,
      dislikes,
      stars: likes + dislikes ? Math.round((likes / (likes + dislikes)) * 50) / 10 : null,
      likePercent: likes + dislikes ? Math.round((likes / (likes + dislikes)) * 100) : null,
      language: ve.titleLocalized?.language || '',
      orientation: ve.orientation || '',
      resolution: Array.isArray(vm.resolution) ? vm.resolution.join('x') : '',
      subs: subsSrc
        .map((t) => ({ label: t.label, lang: t.lang, url: t.urls?.vtt, original: !!t.isOriginal }))
        .filter((t) => t.url),
      thumb: vm.thumbURL || '',
      url: vm.pageURL || url,
    };
  }

  async function fetchVideoInfo(url, signal) {
    const r = await nativeFetch(url, { credentials: 'include', signal });
    if (!r.ok) throw new Error(`Video page HTTP ${r.status}`);
    const html = await r.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const candidates = new Set();
    doc.querySelectorAll('link[rel="preload"][href*=".m3u8"]').forEach((l) => {
      candidates.add(new URL(l.getAttribute('href'), url).href);
    });
    for (const m of html.matchAll(/https?:(?:\\?\/){2}[^"'\s<>\\]+?\.m3u8[^"'\s<>\\]*/g)) {
      candidates.add(m[0].replace(/\\\//g, '/').replace(/&amp;/g, '&'));
    }
    const meta = metaFromInitials(parseInitials(html), url);
    return {
      url,
      vid: vidFromUrl(url),
      title: meta.title || doc.querySelector('h1')?.textContent?.trim() || 'video',
      thumb: meta.thumb || doc.querySelector('meta[property="og:image"]')?.getAttribute('content') || '',
      candidates: [...candidates],
      meta,
      fetched: Date.now(),
    };
  }

  function currentPageInfo() {
    const meta = metaFromInitials(window.initials, location.href);
    return {
      url: location.href,
      vid: vidFromUrl(location.href),
      title: meta.title || document.querySelector('h1')?.textContent?.trim() || document.title,
      thumb: meta.thumb || document.querySelector('meta[property="og:image"]')?.content || '',
      candidates: [...seen, ...[...document.querySelectorAll('link[rel="preload"][href*=".m3u8"]')].map((l) => l.href)],
      meta,
      fetched: Date.now(),
    };
  }

  // ======================= 4. temp storage (only this script's own "xhdl" folder) =======================
  const Store = (() => {
    let probe = null;
    function dir() {
      if (!probe) {
        probe = (async () => {
          try {
            if (!navigator.storage?.getDirectory) return null;
            const root = await navigator.storage.getDirectory();
            const d = await root.getDirectoryHandle('xhdl', { create: true });
            const h = await d.getFileHandle('.probe', { create: true });
            if (typeof h.createWritable !== 'function') return null;
            const w = await h.createWritable();
            await w.write(new Uint8Array([1]));
            await w.close();
            await d.removeEntry('.probe');
            return d;
          } catch (e) {
            log('disk storage unavailable, using memory:', e.message);
            return null;
          }
        })();
      }
      return probe;
    }
    async function job(id) {
      const d = await dir();
      return d ? d.getDirectoryHandle(id, { create: true }) : null;
    }
    async function readManifest(jd) {
      try { return JSON.parse(await (await (await jd.getFileHandle('manifest.json')).getFile()).text()); } catch { return null; }
    }
    async function writeManifest(jd, m) {
      const w = await (await jd.getFileHandle('manifest.json', { create: true })).createWritable();
      await w.write(JSON.stringify(m));
      await w.close();
    }
    async function remove(id) {
      const d = await dir();
      if (!d || !id) return;
      try { await d.removeEntry(id, { recursive: true }); } catch {}
    }
    async function list() {
      const d = await dir();
      const out = [];
      if (!d) return out;
      for await (const [name, h] of d.entries()) {
        if (h.kind === 'directory') out.push({ name, handle: h, manifest: await readManifest(h) });
      }
      return out;
    }
    async function size(h) {
      let n = 0;
      for await (const [, c] of h.entries()) n += c.kind === 'file' ? (await c.getFile()).size : await size(c);
      return n;
    }
    return { dir, job, readManifest, writeManifest, remove, list, size };
  })();

  const segName = (i) => `s${String(i).padStart(6, '0')}`;
  const isSegName = (n) => /^s\d{6}$/.test(n);
  const readFile = async (jd, name) => (await (await jd.getFileHandle(name)).getFile()).arrayBuffer();

  async function janitor() {
    const now = Date.now();
    for (const it of await Store.list()) {
      const m = it.manifest;
      const leftover = m && m.state === 'saved' && now - (m.savedAt || 0) > SAVED_TTL_MS;
      const stale = !m || (m.state !== 'saved' && now - (m.updated || 0) > STALE_MS);
      if (leftover || stale) {
        await Store.remove(it.name);
        log('removed temp files:', it.name);
      }
    }
  }

  async function cacheInfo() {
    let bytes = 0;
    const items = await Store.list();
    for (const it of items) bytes += await Store.size(it.handle);
    return { jobs: items.length, bytes, mb: +(bytes / 1048576).toFixed(1) };
  }

  async function clearCache() {
    for (const it of await Store.list()) await Store.remove(it.name);
  }

  async function partialFor(vid) {
    for (const it of await Store.list()) {
      const m = it.manifest;
      if (!m || m.vid !== vid || m.state === 'saved') continue;
      if (m.mode !== 'segments') return null;
      let n = 0;
      for await (const [name, h] of it.handle.entries()) {
        if (h.kind === 'file' && isSegName(name) && (await h.getFile()).size > 0) n++;
      }
      return n / m.total;
    }
    return undefined;
  }

  // ======================= 5. MKV muxer (fragmented MP4 in, Matroska out, no re-encoding) =======================
  const enc = new TextEncoder();
  const str4 = (v, p) => String.fromCharCode(v.getUint8(p), v.getUint8(p + 1), v.getUint8(p + 2), v.getUint8(p + 3));

  function* boxes(v, start, end) {
    let p = start;
    while (p + 8 <= end) {
      let size = v.getUint32(p);
      const type = str4(v, p + 4);
      let hdr = 8;
      if (size === 1) { size = Number(v.getBigUint64(p + 8)); hdr = 16; } else if (size === 0) size = end - p;
      if (size < hdr || p + size > end) return;
      yield { type, start: p, body: p + hdr, end: p + size };
      p += size;
    }
  }
  const findChild = (v, start, end, type) => { for (const b of boxes(v, start, end)) if (b.type === type) return b; return null; };
  const child = (v, box, type) => findChild(v, box.body, box.end, type);
  const boxPath = (v, box, ...types) => { let b = box; for (const t of types) b = b && child(v, b, t); return b; };

  function parseEsds(v, box) {
    let p = box.body + 4;
    const desc = () => {
      const tag = v.getUint8(p++);
      let len = 0;
      for (let i = 0; i < 4; i++) { const b = v.getUint8(p++); len = (len << 7) | (b & 0x7f); if (!(b & 0x80)) break; }
      return { tag, len };
    };
    let d = desc();
    if (d.tag !== 3) return null;
    p += 2;
    const flags = v.getUint8(p++);
    if (flags & 0x80) p += 2;
    if (flags & 0x40) p += v.getUint8(p) + 1;
    if (flags & 0x20) p += 2;
    d = desc();
    if (d.tag !== 4) return null;
    p += 13;
    d = desc();
    if (d.tag !== 5) return null;
    return new Uint8Array(v.buffer.slice(p, p + d.len));
  }

  const VIDEO_CODECS = {
    av01: ['V_AV1', 'av1C'], avc1: ['V_MPEG4/ISO/AVC', 'avcC'], avc3: ['V_MPEG4/ISO/AVC', 'avcC'],
    hvc1: ['V_MPEGH/ISO/HEVC', 'hvcC'], hev1: ['V_MPEGH/ISO/HEVC', 'hvcC'], vp09: ['V_VP9', null],
  };

  function parseInit(buf) {
    const v = new DataView(buf);
    const moov = findChild(v, 0, buf.byteLength, 'moov');
    if (!moov) throw new Error('init segment has no moov box');
    const trex = new Map();
    const mvex = child(v, moov, 'mvex');
    if (mvex) {
      for (const b of boxes(v, mvex.body, mvex.end)) {
        if (b.type === 'trex') trex.set(v.getUint32(b.body + 4), { dur: v.getUint32(b.body + 12), size: v.getUint32(b.body + 16), flags: v.getUint32(b.body + 20) });
      }
    }
    const list = [];
    for (const trak of boxes(v, moov.body, moov.end)) {
      if (trak.type !== 'trak') continue;
      const tkhd = child(v, trak, 'tkhd');
      const mdia = child(v, trak, 'mdia');
      if (!tkhd || !mdia) continue;
      const id = v.getUint32(tkhd.body + (v.getUint8(tkhd.body) === 1 ? 20 : 12));
      const mdhd = child(v, mdia, 'mdhd');
      const timescale = v.getUint32(mdhd.body + (v.getUint8(mdhd.body) === 1 ? 20 : 12));
      const handler = str4(v, child(v, mdia, 'hdlr').body + 8);
      const stsd = boxPath(v, mdia, 'minf', 'stbl', 'stsd');
      if (!stsd) continue;
      const entry = boxes(v, stsd.body + 8, stsd.end).next().value;
      if (!entry) continue;
      const d = trex.get(id) || { dur: 0, size: 0, flags: 0 };
      const t = { id, timescale, defDur: d.dur, defSize: d.size, defFlags: d.flags };
      if (handler === 'vide') {
        const map = VIDEO_CODECS[entry.type];
        if (!map) throw new Error(`unsupported video codec ${entry.type}`);
        t.kind = 'video';
        t.codecId = map[0];
        t.width = v.getUint16(entry.body + 24);
        t.height = v.getUint16(entry.body + 26);
        if (map[1]) {
          const c = findChild(v, entry.body + 78, entry.end, map[1]);
          if (!c) throw new Error(`missing ${map[1]}`);
          t.priv = new Uint8Array(buf.slice(c.body, c.end));
        }
      } else if (handler === 'soun') {
        t.kind = 'audio';
        const ver = v.getUint16(entry.body + 8);
        t.channels = v.getUint16(entry.body + 16);
        t.rate = v.getUint32(entry.body + 24) >>> 16;
        const childStart = entry.body + 28 + (ver === 1 ? 16 : ver === 2 ? 36 : 0);
        if (entry.type === 'mp4a') {
          const esds = findChild(v, childStart, entry.end, 'esds');
          const asc = esds && parseEsds(v, esds);
          if (!asc) throw new Error('AAC config missing');
          t.codecId = 'A_AAC';
          t.priv = asc;
        } else if (entry.type === 'ac-3') t.codecId = 'A_AC3';
        else if (entry.type === 'ec-3') t.codecId = 'A_EAC3';
        else throw new Error(`unsupported audio codec ${entry.type}`);
      } else continue;
      list.push(t);
    }
    if (!list.length) throw new Error('no audio/video tracks found');
    return { list, byId: new Map(list.map((t) => [t.id, t])) };
  }

  function parseFragment(buf, byId) {
    const v = new DataView(buf);
    const out = [];
    for (const moof of boxes(v, 0, buf.byteLength)) {
      if (moof.type !== 'moof') continue;
      for (const traf of boxes(v, moof.body, moof.end)) {
        if (traf.type !== 'traf') continue;
        const tfhd = child(v, traf, 'tfhd');
        if (!tfhd) continue;
        const tf = v.getUint32(tfhd.body) & 0xffffff;
        let p = tfhd.body + 4;
        const t = byId.get(v.getUint32(p));
        p += 4;
        if (!t) continue;
        let base = moof.start;
        if (tf & 0x1) { base = Number(v.getBigUint64(p)); p += 8; }
        if (tf & 0x2) p += 4;
        let dDur = t.defDur; let dSize = t.defSize; let dFlags = t.defFlags;
        if (tf & 0x8) { dDur = v.getUint32(p); p += 4; }
        if (tf & 0x10) { dSize = v.getUint32(p); p += 4; }
        if (tf & 0x20) { dFlags = v.getUint32(p); p += 4; }
        const tfdt = child(v, traf, 'tfdt');
        let dts = 0;
        if (tfdt) dts = v.getUint8(tfdt.body) === 1 ? Number(v.getBigUint64(tfdt.body + 4)) : v.getUint32(tfdt.body + 4);
        let cursor = base;
        for (const trun of boxes(v, traf.body, traf.end)) {
          if (trun.type !== 'trun') continue;
          const ver = v.getUint8(trun.body);
          const fl = v.getUint32(trun.body) & 0xffffff;
          let q = trun.body + 4;
          const count = v.getUint32(q); q += 4;
          if (fl & 0x1) { cursor = base + v.getInt32(q); q += 4; }
          let firstFlags = null;
          if (fl & 0x4) { firstFlags = v.getUint32(q); q += 4; }
          for (let i = 0; i < count; i++) {
            let dur = dDur; let size = dSize; let flags = dFlags; let cto = 0;
            if (fl & 0x100) { dur = v.getUint32(q); q += 4; }
            if (fl & 0x200) { size = v.getUint32(q); q += 4; }
            if (fl & 0x400) { flags = v.getUint32(q); q += 4; } else if (i === 0 && firstFlags !== null) flags = firstFlags;
            if (fl & 0x800) { cto = ver === 0 ? v.getUint32(q) : v.getInt32(q); q += 4; }
            if (cursor + size <= buf.byteLength) {
              out.push({ track: t, dts, pts: dts + cto, dur, offset: cursor, size, key: t.kind !== 'video' || !(flags & 0x10000) });
            }
            dts += dur;
            cursor += size;
          }
        }
      }
    }
    return out;
  }

  function cat(parts) {
    let n = 0;
    for (const p of parts) n += p.length;
    const out = new Uint8Array(n);
    let o = 0;
    for (const p of parts) { out.set(p, o); o += p.length; }
    return out;
  }
  function idB(id) { const a = []; let x = id; while (x > 0) { a.unshift(x & 0xff); x = Math.floor(x / 256); } return Uint8Array.from(a); }
  function sizeB(n, len) {
    if (!len) { len = 1; while (n >= 2 ** (7 * len) - 1) len++; }
    const b = new Uint8Array(len);
    let x = n;
    for (let i = len - 1; i >= 0; i--) { b[i] = x % 256; x = Math.floor(x / 256); }
    b[0] |= 0x80 >> (len - 1);
    return b;
  }
  function uintB(v, len) {
    if (!len) { len = 1; while (v >= 2 ** (8 * len)) len++; }
    const b = new Uint8Array(len);
    let x = v;
    for (let i = len - 1; i >= 0; i--) { b[i] = x % 256; x = Math.floor(x / 256); }
    return b;
  }
  const el = (id, ...parts) => { const body = cat(parts.filter(Boolean)); return cat([idB(id), sizeB(body.length), body]); };
  const U = (id, v, len) => el(id, uintB(v, len));
  const F = (id, v) => { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v); return el(id, b); };
  const S = (id, s) => el(id, enc.encode(String(s)));

  // Accepts "hh:mm:ss.mmm", "mm:ss.mmm" and comma decimals; honours X-TIMESTAMP-MAP if present.
  function parseVtt(txt) {
    const toMs = (t) => {
      const p = t.replace(',', '.').split(':').map(Number);
      const sec = p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : p[0];
      return Math.round(sec * 1000);
    };
    const clean = txt.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
    let mapOffset = null;
    const map = /X-TIMESTAMP-MAP=([^\n]+)/.exec(clean);
    if (map) {
      const mpegts = +((/MPEGTS:(\d+)/.exec(map[1]) || [])[1] || 0);
      const local = (/LOCAL:([\d:.,]+)/.exec(map[1]) || [])[1];
      mapOffset = Math.round(mpegts / 90) - (local ? toMs(local) : 0);
    }
    const cues = [];
    for (const block of clean.split(/\n{2,}/)) {
      const lines = block.split('\n');
      const i = lines.findIndex((l) => l.includes('-->'));
      if (i < 0) continue;
      const [a, b] = lines[i].split('-->').map((s) => s.trim().split(/\s+/)[0]);
      const start = toMs(a);
      const end = toMs(b);
      const text = lines.slice(i + 1).join('\n').replace(/<[^>]+>/g, '').trim();
      if (Number.isFinite(start) && Number.isFinite(end) && text && end > start) cues.push({ start, end, text });
    }
    return { cues, mapOffset };
  }

  async function getTextRetry(url, signal) {
    for (let attempt = 0; ; attempt++) {
      try { return await getText(url, signal); } catch (e) {
        if (e.name === 'AbortError' || attempt >= 2) throw e;
        await sleep(700 * (attempt + 1), signal);
      }
    }
  }

  // Last resort when the site offers no English track: the browser's built-in, on-device
  // Translator API (Chrome/Edge 138+). Firefox does not provide it to web pages.
  async function translateTrack(src, signal) {
    const T = window.Translator;
    if (!T?.create || !src?.lang) return null;
    try {
      const opts = { sourceLanguage: src.lang.split(/[-_]/)[0], targetLanguage: 'en' };
      if (T.availability && (await T.availability(opts)) === 'unavailable') return null;
      const tr = await T.create(opts);
      const cues = [];
      for (const c of src.cues) {
        if (signal?.aborted) throw abortErr();
        cues.push({ ...c, text: await tr.translate(c.text) });
      }
      tr.destroy?.();
      return { label: 'English (translated on this device)', lang: 'en', original: false, isEnglish: true, cues, mapOffset: src.mapOffset };
    } catch (e) {
      if (e.name === 'AbortError') throw e;
      log('on-device translation unavailable:', e.message);
      return null;
    }
  }

  // English first (always, when it exists or can be made), then the original language, then SUB_LANGS extras.
  const isEnglish = (t) => /^en(?:$|[-_])/i.test(t.lang || '') || /english/i.test(t.label || '');

  async function loadSubs(info, signal) {
    const tracks = info.meta?.subs || [];
    const want = [];
    const english = tracks.filter(isEnglish).sort((a, b) => (b.original ? 1 : 0) - (a.original ? 1 : 0))[0];
    if (english) want.push(english);
    const orig = tracks.find((t) => t.original);
    if (orig && !want.includes(orig)) want.push(orig);
    for (const lang of SUB_LANGS) {
      const t = tracks.find((x) => x.lang === lang);
      if (t && !want.includes(t)) want.push(t);
    }

    const out = [];
    for (const t of want) {
      try {
        const { cues, mapOffset } = parseVtt(await getTextRetry(t.url, signal));
        if (cues.length) out.push({ ...t, cues, mapOffset, isEnglish: isEnglish(t) });
        else log('subtitle track had no readable cues:', t.label);
      } catch (e) {
        if (e.name === 'AbortError') throw e;
        log('subtitle track skipped:', t.label, e.message);
      }
    }

    if (!out.some((s) => s.isEnglish)) {
      const src = out.find((s) => s.original) || out[0];
      const translated = await translateTrack(src, signal);
      if (translated) out.unshift(translated);
      else if (src) log('no English subtitles from the site, and on-device translation is not available in this browser');
    }
    out.sort((a, b) => (b.isEnglish ? 1 : 0) - (a.isEnglish ? 1 : 0));
    log(`subtitles added: ${out.map((s) => s.label).join(', ') || 'none'} (site offers ${tracks.length})`);
    return out;
  }

  // Thumbnail as MKV cover art (converted to JPEG where the browser allows).
  async function loadCover(info, signal) {
    const src = info.meta?.thumb || info.thumb;
    if (!src) return null;
    try {
      const r = await nativeFetch(src, { credentials: 'omit', signal });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      let blob = await r.blob();
      let mime = blob.type || 'image/jpeg';
      if (mime !== 'image/jpeg' && typeof OffscreenCanvas !== 'undefined') {
        try {
          const bmp = await createImageBitmap(blob);
          const c = new OffscreenCanvas(bmp.width, bmp.height);
          c.getContext('2d').drawImage(bmp, 0, 0);
          blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.92 });
          mime = 'image/jpeg';
        } catch {}
      }
      const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp';
      return { data: new Uint8Array(await blob.arrayBuffer()), mime, name: `cover.${ext}` };
    } catch (e) {
      if (e.name === 'AbortError') throw e;
      log('cover art skipped:', e.message);
      return null;
    }
  }

  function buildTags(info, title) {
    const m = info.meta || {};
    const out = [];
    const add = (name, value) => {
      if (value === null || value === undefined || value === '') return;
      out.push(el(0x67C8, S(0x45A3, name), S(0x4487, String(value))));
    };
    add('TITLE', title);
    add('ARTIST', m.uploader);
    add('UPLOADER_URL', m.uploaderUrl);
    if (m.creator && m.creator !== m.uploader) add('CREATOR', m.creator);
    add('URL', m.creatorUrl || m.uploaderUrl);          // creator page
    add('PROMOTION_URL', m.url || info.url);             // the page the video is hosted on
    (m.people || []).forEach((p) => add('ACTOR', p));
    (m.channels || []).forEach((c) => add('CHANNEL', c));
    add('PUBLISHER', 'xHamster');
    add('DATE_RELEASED', m.created ? new Date(m.created * 1000).toISOString().slice(0, 10) : '');
    add('DESCRIPTION', m.description);
    add('COMMENT', m.description);
    add('GENRE', (m.categories || []).join('; '));
    add('KEYWORDS', (m.tags || []).join(', '));
    add('RATING', m.stars);                      // 0–5, 5 = 100% likes
    add('LIKES', m.likes || null);
    add('DISLIKES', m.dislikes || null);
    add('LIKE_PERCENT', m.likePercent);
    add('VIEWS', m.views);
    add('COMMENT_COUNT', m.comments);
    add('ORIGINAL_LANGUAGE', m.language);
    add('ORIENTATION', m.orientation);
    add('SOURCE_RESOLUTION', m.resolution);
    add('VIDEO_ID', m.idHash || m.id);
    return el(0x1254C367, el(0x7373, el(0x63C0, U(0x68CA, 50)), ...out));
  }

  async function muxMKV({ sink, initBuf, segCount, segmentSource, subs, cover, info, signal, onProgress }) {
    const trk = parseInit(initBuf);
    let num = 1;
    for (const t of trk.list) t.num = num++;
    const subTracks = subs.map((s) => ({ ...s, num: num++ }));
    const videoNum = trk.list.find((t) => t.kind === 'video')?.num || 1;
    const uid = () => Math.floor(Math.random() * 0xfffffff0) + 1;
    const title = info.title || info.meta?.title || 'video';

    await sink.write(el(0x1A45DFA3, U(0x4286, 1), U(0x42F7, 1), U(0x42F2, 4), U(0x42F3, 8), S(0x4282, 'matroska'), U(0x4287, 4), U(0x4285, 2)));
    await sink.write(idB(0x18538067));
    const segSizePos = sink.pos;
    await sink.write(sizeB(0, 8));
    const segStart = sink.pos;

    const infoBody = cat([F(0x4489, 0), U(0x2AD7B1, 1000000), S(0x4D80, 'xhdl'), S(0x5741, 'xhdl userscript'), S(0x7BA9, title)]);
    const infoHead = cat([idB(0x1549A966), sizeB(infoBody.length)]);
    const infoEl = cat([infoHead, infoBody]);
    const durOffset = infoHead.length + 3;

    const trackEls = trk.list.map((t) => el(0xAE,
      U(0xD7, t.num), U(0x73C5, uid()), U(0x83, t.kind === 'video' ? 1 : 2), U(0x9C, 0), S(0x86, t.codecId),
      t.priv ? el(0x63A2, t.priv) : null, S(0x22B59C, 'und'),
      t.kind === 'video' ? el(0xE0, U(0xB0, t.width), U(0xBA, t.height)) : el(0xE1, F(0xB5, t.rate || 48000), U(0x9F, t.channels || 2))));
    const subEls = subTracks.map((s) => el(0xAE,
      U(0xD7, s.num), U(0x73C5, uid()), U(0x83, 17), U(0x9C, 0), S(0x86, 'S_TEXT/UTF8'),
      S(0x22B59C, s.isEnglish ? 'eng' : 'und'), S(0x22B59D, s.isEnglish ? 'en' : s.lang || 'und'), S(0x536E, s.label || s.lang || 'Subtitles'),
      U(0x88, s.isEnglish ? 1 : 0)));
    const tracksEl = el(0x1654AE6B, ...trackEls, ...subEls);
    const tagsEl = buildTags(info, title);
    const attEl = cover
      ? el(0x1941A469, el(0x61A7, S(0x467E, 'Thumbnail'), S(0x466E, cover.name), S(0x4660, cover.mime), el(0x465C, cover.data), U(0x46AE, uid())))
      : null;

    const heads = [['info', 0x1549A966, infoEl], ['tracks', 0x1654AE6B, tracksEl], ['tags', 0x1254C367, tagsEl]];
    if (attEl) heads.push(['att', 0x1941A469, attEl]);
    const seekEntry = (id, p) => el(0x4DBB, el(0x53AB, idB(id)), U(0x53AC, p, 8));
    const seekHead = (pos, cuesPos) => el(0x114D9B74, ...heads.map(([k, id]) => seekEntry(id, pos[k] || 0)), seekEntry(0x1C53BB6B, cuesPos));
    const seekLen = seekHead({}, 0).length;
    const pos = {};
    let at = seekLen;
    for (const [k, , bytes] of heads) { pos[k] = at; at += bytes.length; }
    const headerEnd = at;
    const infoPos = sink.pos + seekLen;
    await sink.write(seekHead(pos, 0));
    for (const [, , bytes] of heads) await sink.write(bytes);

    // Subtitle times share the video's zero point. Tracks with an X-TIMESTAMP-MAP are shifted onto the
    // media clock first; the list is built once the first video timestamp (t0) is known.
    let allCues = [];
    const buildCues = () => {
      allCues = subTracks
        .flatMap((s) => s.cues.map((c) => {
          const shift = s.mapOffset != null ? s.mapOffset - t0 : 0;
          return { start: c.start + shift, end: c.end + shift, text: c.text, num: s.num };
        }))
        .filter((c) => c.end > 0)
        .map((c) => ({ ...c, start: Math.max(0, c.start) }))
        .sort((a, b) => a.start - b.start || a.num - b.num);
    };
    let subIdx = 0;
    const cuePoints = [];
    let cluster = null;
    let t0 = null;
    let endMs = 0;

    const flush = async () => {
      if (!cluster || !cluster.parts.length) { cluster = null; return; }
      const clusterPos = sink.pos - segStart;
      const tcEl = U(0xE7, cluster.tc);
      await sink.write([idB(0x1F43B675), sizeB(tcEl.length + cluster.len), tcEl, ...cluster.parts]);
      if (cluster.cue || !cuePoints.length) cuePoints.push({ tc: cluster.tc, pos: clusterPos });
      cluster = null;
    };
    const addBlock = async (abs, make) => {
      if (!cluster || abs - cluster.tc > 32000) {
        await flush();
        cluster = { tc: Math.max(0, abs), parts: [], len: 0, cue: false };
      }
      const b = make(Math.max(-32768, Math.min(32767, abs - cluster.tc)));
      cluster.parts.push(...b.parts);
      cluster.len += b.len;
    };
    const simpleBlock = (n, key, data) => (rel) => {
      const head = cat([idB(0xA3), sizeB(4 + data.byteLength), Uint8Array.of(0x80 | n, (rel >> 8) & 0xff, rel & 0xff, key ? 0x80 : 0)]);
      return { parts: [head, data], len: head.length + data.byteLength };
    };
    const subBlock = (c) => (rel) => {
      const g = el(0xA0, el(0xA1, Uint8Array.of(0x80 | c.num, (rel >> 8) & 0xff, rel & 0xff, 0), enc.encode(c.text)), U(0x9B, Math.max(1, c.end - c.start)));
      return { parts: [g], len: g.length };
    };

    for (let i = 0; i < segCount; i++) {
      if (signal?.aborted) throw abortErr();
      const buf = await segmentSource(i);
      const samples = parseFragment(buf, trk.byId);
      if (samples.length) {
        for (const s of samples) {
          const ts = s.track.timescale;
          s.ms = Math.round((s.pts * 1000) / ts);
          s.dms = Math.round((s.dts * 1000) / ts);
          s.durMs = Math.round((s.dur * 1000) / ts);
        }
        if (t0 === null) { t0 = Math.min(...samples.map((s) => s.ms)); buildCues(); }
        samples.sort((a, b) => a.dms - b.dms);
        await flush();
        const firstVideo = samples.find((s) => s.track.kind === 'video');
        cluster = { tc: Math.max(0, Math.min(...samples.map((s) => s.ms)) - t0), parts: [], len: 0, cue: !firstVideo || firstVideo.key };
        for (const s of samples) {
          const abs = Math.max(0, s.ms - t0);
          while (subIdx < allCues.length && allCues[subIdx].start <= abs) {
            const c = allCues[subIdx++];
            await addBlock(c.start, subBlock(c));
          }
          await addBlock(abs, simpleBlock(s.track.num, s.key, new Uint8Array(buf, s.offset, s.size)));
          endMs = Math.max(endMs, abs + s.durMs);
        }
      }
      onProgress?.((i + 1) / segCount);
    }
    while (subIdx < allCues.length) {
      const c = allCues[subIdx++];
      await addBlock(c.start, subBlock(c));
    }
    await flush();

    const cuesPos = sink.pos - segStart;
    if (!cuePoints.length) cuePoints.push({ tc: 0, pos: headerEnd });
    await sink.write(el(0x1C53BB6B, ...cuePoints.map((c) => el(0xBB, U(0xB3, c.tc), el(0xB7, U(0xF7, videoNum), U(0xF1, c.pos))))));

    const durBytes = new Uint8Array(8);
    new DataView(durBytes.buffer).setFloat64(0, endMs || 1);
    await sink.patch(infoPos + durOffset, durBytes);
    await sink.patch(segStart + seekLen - 8, uintB(cuesPos, 8));
    await sink.patch(segSizePos, sizeB(sink.pos - segStart, 8));
  }

  async function opfsSink(fh) {
    const w = await fh.createWritable();
    let pos = 0;
    return {
      get pos() { return pos; },
      async write(data) {
        const blob = Array.isArray(data) ? new Blob(data) : data;
        const len = blob instanceof Blob ? blob.size : blob.byteLength;
        await w.write({ type: 'write', position: pos, data: blob });
        pos += len;
      },
      async patch(at, u8) { await w.write({ type: 'write', position: at, data: u8 }); },
      close: () => w.close(),
      async abort() { try { await w.abort(); } catch {} },
    };
  }

  function memorySink() {
    const parts = [];
    const starts = [];
    let pos = 0;
    return {
      get pos() { return pos; },
      async write(data) {
        for (const u of Array.isArray(data) ? data : [data]) { starts.push(pos); parts.push(u); pos += u.byteLength; }
      },
      async patch(at, u8) {
        for (let i = parts.length - 1; i >= 0; i--) {
          if (starts[i] <= at) { parts[i].set(u8, at - starts[i]); return; }
        }
      },
      blob() { return new Blob(parts, { type: 'video/x-matroska' }); },
    };
  }

  // ======================= 6. download engine =======================
  async function opfsSegments(jd, list, signal, onProgress, onBytes) {
    const have = new Set();
    let bytes = 0;
    for await (const [name, h] of jd.entries()) {
      if (h.kind !== 'file' || !isSegName(name)) continue;
      const size = (await h.getFile()).size;
      if (size > 0) { have.add(name); bytes += size; }
    }
    const todo = list.map((_, i) => i).filter((i) => !have.has(segName(i)));
    let done = list.length - todo.length;
    if (done) log(`resuming: ${done}/${list.length} segments already on disk`);
    onProgress(done / list.length);
    onBytes(bytes);
    let next = 0;
    let stop = false;
    const worker = async () => {
      while (!stop && next < todo.length) {
        if (signal.aborted) throw abortErr();
        const i = todo[next++];
        try {
          const buf = await fetchBuffer(list[i], signal);
          const w = await (await jd.getFileHandle(segName(i), { create: true })).createWritable();
          try { await w.write(buf); await w.close(); } catch (e) { try { await w.abort(); } catch {} throw e; }
          bytes += buf.byteLength;
        } catch (e) { stop = true; throw e; }
        onProgress(++done / list.length);
        onBytes(bytes);
      }
    };
    await Promise.all(Array.from({ length: Math.min(CONCURRENCY, todo.length) }, worker));
  }

  async function segmentsBlob(jd, count) {
    const files = [];
    for (let i = 0; i < count; i++) files.push(await (await jd.getFileHandle(segName(i))).getFile());
    return new Blob(files, { type: 'video/mp4' });
  }

  async function opfsSingle(jd, url, signal, onProgress, onBytes) {
    const h = await jd.getFileHandle('whole', { create: true });
    let have = (await h.getFile()).size;
    const r = await nativeFetch(url, { credentials: 'omit', signal, headers: have ? { Range: `bytes=${have}-` } : {} });
    if (r.status === 416) return h.getFile();
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    if (have && r.status !== 206) have = 0;
    const total = r.status === 206
      ? +((/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] || 0)
      : +r.headers.get('content-length') || 0;
    const w = await h.createWritable({ keepExistingData: have > 0 });
    if (have) await w.seek(have);
    let got = have;
    try {
      const reader = r.body.getReader();
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        await w.write(value);
        got += value.length;
        onBytes(got);
        if (total) onProgress(got / total);
      }
      await w.close();
    } catch (e) {
      try { await w.close(); } catch {}
      throw e;
    }
    return h.getFile();
  }

  async function memSegments(list, signal, onProgress, onBytes) {
    const out = new Array(list.length);
    let next = 0;
    let done = 0;
    let bytes = 0;
    let stop = false;
    const worker = async () => {
      while (!stop && next < list.length) {
        const i = next++;
        try { out[i] = await fetchBuffer(list[i], signal); } catch (e) { stop = true; throw e; }
        bytes += out[i].byteLength;
        onProgress(++done / list.length);
        onBytes(bytes);
      }
    };
    await Promise.all(Array.from({ length: Math.min(CONCURRENCY, list.length) }, worker));
    return out;
  }

  async function memSingle(url, signal, onProgress, onBytes) {
    const r = await nativeFetch(url, { credentials: 'omit', signal });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const total = +r.headers.get('content-length') || 0;
    const reader = r.body.getReader();
    const chunks = [];
    let got = 0;
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      got += value.length;
      onBytes(got);
      if (total) onProgress(got / total);
    }
    return new Blob(chunks, { type: 'video/mp4' });
  }

  async function downloadVideo({ info, variant, format, signal, onStatus = () => {}, onProgress = () => {}, onBytes = () => {}, onJob = () => {} }) {
    onStatus('Downloading');
    const codec = /\.av1\./.test(variant.url) ? 'av1' : /\.h264\./.test(variant.url) ? 'h264' : 'std';
    const mediaTxt = variant.mediaText || (await getText(variant.url, signal));
    const { init, segs, encrypted } = parseMedia(mediaTxt, variant.url);
    if (encrypted) throw new Error('This stream is encrypted; download is not supported.');
    if (!segs.length) throw new Error('Playlist contained no segments.');

    const list = init ? [init, ...segs] : segs;
    const single = new Set(list).size === 1;
    let wantMkv = format === 'mkv';
    let noteText = '';
    if (wantMkv && (single || !init)) { wantMkv = false; noteText = 'MKV not possible for this stream, saved as MP4'; }
    const share = wantMkv ? 0.9 : 1;
    const dlProgress = (p) => onProgress(p * share);
    const convProgress = (p) => onProgress(0.9 + 0.1 * p);
    const jobId = `${info.vid}_${variant.label}_${codec}`.replace(/[^\w.-]+/g, '_');
    onJob(jobId);

    let jd = await Store.job(jobId);
    let blob = null;
    let ext = 'mp4';
    let subsInfo = '';
    const mux = async (sink, initBuf, source) => {
      onStatus('Converting');
      const [subs, cover] = await Promise.all([loadSubs(info, signal), loadCover(info, signal)]);
      subsInfo = subs.length ? `Subtitles: ${subs.map((x) => x.label).join(', ')}` : 'No subtitles available';
      await muxMKV({ sink, initBuf, segCount: list.length - 1, segmentSource: source, subs, cover, info, signal, onProgress: convProgress });
    };
    const mkvFailed = (e) => {
      if (e.name === 'AbortError') throw e;
      log('MKV conversion failed, saving MP4:', e);
      noteText = `MKV conversion failed (${e.message}), saved as MP4`;
    };

    if (!jd) {
      if (single) {
        blob = await memSingle(list[0], signal, dlProgress, onBytes);
      } else {
        const bufs = await memSegments(list, signal, dlProgress, onBytes);
        if (wantMkv) {
          try {
            const sink = memorySink();
            await mux(sink, bufs[0], async (i) => bufs[i + 1]);
            blob = sink.blob();
            ext = 'mkv';
          } catch (e) { mkvFailed(e); }
        }
        if (!blob) blob = new Blob(bufs, { type: 'video/mp4' });
      }
    } else {
      const mode = single ? 'single' : 'segments';
      const old = await Store.readManifest(jd);
      if (old && (old.mode !== mode || old.total !== list.length)) {
        await Store.remove(jobId);
        jd = await Store.job(jobId);
      }
      const manifest = { jobId, vid: info.vid, title: info.title, label: variant.label, codec, mode, total: list.length, state: 'partial', updated: Date.now() };
      await Store.writeManifest(jd, manifest);
      if (single) {
        blob = await opfsSingle(jd, list[0], signal, dlProgress, onBytes);
      } else {
        await opfsSegments(jd, list, signal, dlProgress, onBytes);
        if (wantMkv) {
          let sink = null;
          try {
            const fh = await jd.getFileHandle('out.mkv', { create: true });
            sink = await opfsSink(fh);
            await mux(sink, await readFile(jd, segName(0)), (i) => readFile(jd, segName(i + 1)));
            await sink.close();
            sink = null;
            for (let i = 0; i < list.length; i++) { try { await jd.removeEntry(segName(i)); } catch {} }
            blob = await fh.getFile();
            ext = 'mkv';
          } catch (e) {
            if (sink) await sink.abort();
            mkvFailed(e);
          }
        }
        if (!blob) blob = await segmentsBlob(jd, list.length);
      }
      await Store.writeManifest(jd, { ...manifest, state: 'saved', savedAt: Date.now(), updated: Date.now() });
    }

    onProgress(1);
    return { blob, filename: `${cleanName(info.title)} [${variant.label}].${ext}`, jobId: jd ? jobId : null, note: noteText, subsInfo: ext === 'mkv' ? subsInfo : '' };
  }

  // Normal browser download, then remove the temp copy automatically
  // (about 30 s plus 1 s per 25 MB, since a page can't see when the browser finishes writing).
  function saveAndCleanup(blob, filename, jobId) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    const delay = 30000 + Math.ceil((blob.size || 0) / (25 * 1048576)) * 1000;
    setTimeout(() => {
      URL.revokeObjectURL(url);
      Store.remove(jobId);
    }, delay);
  }

  async function prepare(q, signal) {
    if (!q.info || Date.now() - q.info.fetched > INFO_TTL_MS) {
      q.info = await fetchVideoInfo(q.url, signal);
      q.variants = null;
    }
    if (!q.variants) q.variants = await getVariants(q.info.candidates, signal);
    q.variant = chooseVariant(q.variants, q.quality);
    q.quality = q.variant.label;
    q.sizeEst = estSize(q.variants, q.quality, q.info.meta?.duration);
    if (q.info.title) q.title = q.info.title;
    if (!q.thumb) q.thumb = q.info.thumb;
  }

  async function runItem(q, panel) {
    const signal = q.ctrl.signal;
    await prepare(q, signal);
    panel.update(q);
    const res = await downloadVideo({
      info: q.info, variant: q.variant, format: q.format, signal,
      onJob: (id) => { q.jobId = id; },
      onStatus: (s) => { q.status = s; panel.update(q); },
      onProgress: (p) => { q.pct = p; panel.update(q); q.onProgress?.(p); },
      onBytes: (b) => { q.bytes = b; },
    });
    q.status = 'Saving';
    panel.update(q);
    saveAndCleanup(res.blob, res.filename, res.jobId);
    q.sizeFinal = res.blob.size;
    q.note = res.note;
    q.subsInfo = res.subsInfo;
    q.status = res.note ? 'Done (MP4)' : 'Done';
    q.pct = 1;
    q.phase = 'done';
    panel.update(q);
  }

  // ======================= 7. site-styled buttons =======================
  const ICON_DOWNLOAD = 'M9 3h2v8.17l2.59-2.58L15 10l-5 5-5-5 1.41-1.41L9 11.17V3zM4 15h12v2H4z';
  const ICON_LIST = 'M3 4h2v2H3zm4 0h10v2H7zM3 9h2v2H3zm4 0h10v2H7zm-4 5h2v2H3zm4 0h10v2H7z';
  const ICON_CLOSE = 'M5.3 4.3 10 9l4.7-4.7 1 1L11 10l4.7 4.7-1 1L10 11l-4.7 4.7-1-1L9 10 4.3 5.3z';
  const ICON_GEAR = 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L2.74 8.87a.47.47 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.48.48 0 0 0-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z';

  function makeSiteButton(iconPath, viewBox = '0 0 20 20') {
    const tpl = document.querySelector('[data-role="video-share-button"] button');
    let btn;
    let labelEl;
    if (tpl) {
      btn = tpl.cloneNode(true);
      btn.removeAttribute('__xid');
      const svg = btn.querySelector('svg');
      if (svg) { svg.setAttribute('viewBox', viewBox); svg.innerHTML = `<path fill="currentColor" d="${iconPath}"/>`; }
      labelEl = btn.querySelector('[class*="text-"]') || btn.querySelector('span:last-of-type');
    } else {
      btn = document.createElement('button');
      btn.className = 'xh-button button gray large';
      btn.innerHTML = `<svg viewBox="${viewBox}" width="20"><path fill="currentColor" d="${iconPath}"/></svg> <span></span>`;
      labelEl = btn.querySelector('span');
    }
    btn.type = 'button';
    return {
      btn,
      setText: (t) => {
        if (!labelEl || labelEl.textContent === t) return;
        labelEl.textContent = t;
        labelEl.style.display = t ? '' : 'none';
      },
    };
  }

  const chipHtml = (group, value, text, on) =>
    `<span class="xhdl-chip${on ? ' on' : ''}" data-chip="${group}" data-v="${escHtml(value)}">${escHtml(text)}</span>`;

  // ======================= 8. progress popup (single + batch share one layout) =======================
  let previewEl = null;
  function showPreview(src, anchor) {
    if (!src) return;
    if (!previewEl) { previewEl = document.createElement('img'); previewEl.className = 'xhdl-preview'; document.body.appendChild(previewEl); }
    previewEl.src = src;
    const r = anchor.getBoundingClientRect();
    previewEl.style.left = `${Math.min(innerWidth - 330, r.right + 10)}px`;
    previewEl.style.top = `${Math.max(8, Math.min(innerHeight - 190, r.top - 70))}px`;
    previewEl.style.display = 'block';
  }
  const hidePreview = () => { if (previewEl) previewEl.style.display = 'none'; };

  function createPanel({ queue, numbered, heading }) {
    const wrap = document.createElement('div');
    wrap.className = 'xhdl-backdrop';
    wrap.innerHTML = `
      <div class="xhdl-modal" role="dialog" aria-modal="true">
        <h3 class="xhdl-title"></h3>
        <p class="xhdl-warn">⚠️ Keep this tab open until the download${queue.length > 1 ? 's finish' : ' finishes'}. Leaving the page stops it; finished segments are kept so the next attempt resumes.</p>
        <div class="xhdl-tablewrap">
          <table class="xhdl-table">
            <thead><tr>${numbered ? '<th>#</th>' : ''}<th>Thumbnail</th><th>Link</th><th>Video</th><th>Status</th><th>Quality</th><th>Format</th><th>Size</th><th>Progress</th></tr></thead>
            <tbody></tbody>
          </table>
        </div>
        <div class="xhdl-confirm" hidden>
          <p></p>
          <div class="xhdl-confirm-btns">
            <button type="button" class="xhdl-btn" data-act="continue">Continue download</button>
            <button type="button" class="xhdl-btn xhdl-danger" data-act="delete">Cancel &amp; delete partial</button>
          </div>
        </div>
        <div class="xhdl-foot">
          <span class="xhdl-cache"></span>
          <button type="button" class="xhdl-btn xhdl-clear" hidden>Clear unfinished downloads</button>
          <button type="button" class="xhdl-btn xhdl-main">Cancel</button>
        </div>
      </div>`;
    document.body.appendChild(wrap);
    const modal = wrap.firstElementChild;
    const $ = (s) => modal.querySelector(s);
    const title = $('.xhdl-title');
    title.textContent = heading;
    const tbody = $('tbody');
    const mainBtn = $('.xhdl-main');
    const confirmBox = $('.xhdl-confirm');
    let closed = false;

    for (const q of queue) {
      const tr = document.createElement('tr');
      tr.innerHTML = `${numbered ? '<td class="c-n"></td>' : ''}
        <td class="c-thumb"><div class="xhdl-hover">Hover</div></td>
        <td class="c-link"><a class="xhdl-link" target="_blank" rel="noopener">Open Page</a></td>
        <td class="c-name"></td><td class="c-status"></td>
        <td class="c-q"><select class="xhdl-sel" data-k="quality"></select></td>
        <td class="c-f"><select class="xhdl-sel" data-k="format"><option value="mkv">MKV</option><option value="native">Native</option></select></td>
        <td class="c-size"></td>
        <td class="c-prog"><div class="xhdl-progwrap"><div class="xhdl-bar"><i></i></div><span></span></div></td>`;
      if (numbered) tr.querySelector('.c-n').textContent = q.n;
      tr.querySelector('.xhdl-link').href = q.url;
      tr._q = q;
      q.row = tr;
      tbody.appendChild(tr);
    }

    tbody.addEventListener('change', (e) => {
      const s = e.target.closest('.xhdl-sel');
      const q = s?.closest('tr')?._q;
      if (!q || q.locked) return;
      q[s.dataset.k] = s.value;
      ui.update(q);
    });
    tbody.addEventListener('mouseover', (e) => {
      const h = e.target.closest('.xhdl-hover');
      if (h) showPreview(h.closest('tr')._q.thumb, h);
    });
    tbody.addEventListener('mouseout', (e) => { if (e.target.closest('.xhdl-hover')) hidePreview(); });

    // Green fill, left to right. Each video counts by its size, and its share fills with its own
    // progress, which covers downloading AND converting, so the fill only reaches the end when the
    // last video has finished converting. Failed or cancelled videos drop out of the total.
    const updateFill = () => {
      const live = queue.filter((x) => x.phase !== 'idle' || !/^(Failed|Cancelled)/.test(x.status || ''));
      const size = (x) => x.sizeFinal || x.sizeEst || 0;
      const total = live.reduce((s, x) => s + size(x), 0);
      const prog = (x) => (x.phase === 'done' ? 1 : Math.min(1, x.pct || 0));
      let f;
      if (!live.length) f = 0;
      else if (total > 0) f = live.reduce((s, x) => s + size(x) * prog(x), 0) / total;
      else f = live.reduce((s, x) => s + prog(x), 0) / live.length;
      modal.style.setProperty('--fill', `${Math.min(100, f * 100).toFixed(1)}%`);
    };

    const ui = {
      onMain: null,
      update(q) {
        if (closed || !q.row) return;
        const r = q.row;
        const name = r.querySelector('.c-name');
        if (name.textContent !== q.title) { name.textContent = q.title; name.title = q.title; }
        const pct = Math.floor((q.pct || 0) * 100);
        const st = r.querySelector('.c-status');
        st.textContent = q.status === 'Converting' ? 'Converting to MKV' : q.status;
        st.title = q.error || q.note || q.subsInfo || '';
        st.dataset.state = q.status.split(/[\s–(]/)[0].toLowerCase();

        const qs = r.querySelector('[data-k="quality"]');
        const labels = (q.variants || []).map((v) => v.label);
        const chosen = q.variants ? chooseVariant(q.variants, q.quality).label : '';
        const sig = `${labels.join(',')}|${chosen}|${q.infoError ? 1 : 0}`;
        if (qs.dataset.sig !== sig) {
          qs.dataset.sig = sig;
          qs.innerHTML = labels.length
            ? labels.map((l) => `<option value="${escHtml(l)}"${l === chosen ? ' selected' : ''}>${escHtml(l)}</option>`).join('')
            : `<option>${q.infoError ? 'Unavailable' : 'Loading…'}</option>`;
        }
        qs.disabled = !!q.locked || !labels.length;
        const fs = r.querySelector('[data-k="format"]');
        fs.value = q.format;
        fs.disabled = !!q.locked;

        if (!q.locked && q.variants) q.sizeEst = estSize(q.variants, chosen, q.info?.meta?.duration);
        r.querySelector('.c-size').textContent = q.sizeFinal ? fmtSize(q.sizeFinal) : q.sizeEst ? `≈ ${fmtSize(q.sizeEst)}` : '—';

        r.querySelector('.xhdl-bar i').style.width = `${pct}%`;
        r.querySelector('.c-prog span').textContent = `${pct}%`;
        updateFill();
      },
      setMain(text, disabled = false) { mainBtn.textContent = text; mainBtn.disabled = disabled; },
      ask(q) {
        confirmBox.hidden = false;
        mainBtn.disabled = true;
        confirmBox.querySelector('p').textContent =
          `${numbered ? `#${q.n} ` : ''}“${q.title}” is ${Math.floor(q.pct * 100)}% downloaded. Keep downloading, or cancel and delete its partial files?`;
        return new Promise((resolve) => {
          confirmBox.onclick = (e) => {
            const act = e.target.closest('[data-act]')?.dataset.act;
            if (!act) return;
            confirmBox.hidden = true;
            mainBtn.disabled = false;
            resolve(act);
          };
        });
      },
      async finish(text) {
        title.textContent = text;
        $('.xhdl-warn').hidden = true;
        confirmBox.hidden = true;
        ui.setMain('Close');
        updateFill();
        await showCache();
      },
      close() {
        if (closed) return;
        closed = true;
        hidePreview();
        wrap.remove();
      },
    };

    async function showCache() {
      if (!(await Store.dir())) return;
      const partial = (await Store.list()).filter((it) => it.manifest?.state !== 'saved');
      let bytes = 0;
      for (const it of partial) bytes += await Store.size(it.handle);
      $('.xhdl-cache').textContent = partial.length ? `Unfinished downloads kept for resume: ${fmtSize(bytes)}` : '';
      $('.xhdl-clear').hidden = !partial.length;
    }
    $('.xhdl-clear').addEventListener('click', async () => {
      for (const it of await Store.list()) if (it.manifest?.state !== 'saved') await Store.remove(it.name);
      await showCache();
    });
    mainBtn.addEventListener('click', () => ui.onMain && ui.onMain());
    queue.forEach((q) => ui.update(q));
    return ui;
  }

  // ======================= 9. single-video button + its option boxes =======================
  const single = {
    setText: null, running: false, ctrl: null, q: null, rest: 'Download',
    opts: { quality: settings.quality, format: settings.format }, variants: null, varError: '', varLoading: false,
    optsEl: null, sizeEl: null,
  };

  function renderSingle() {
    if (!single.setText) return;
    if (!single.running) return single.setText(single.rest);
    const q = single.q;
    if (q?.status === 'Downloading') return single.setText(`Downloading ${Math.floor((q.pct || 0) * 100)}%`);
    single.setText(q?.status === 'Converting' ? `Converting ${Math.floor((q.pct || 0) * 100)}%` : 'Preparing…');
  }

  function renderSingleOpts() {
    if (!single.optsEl) return;
    const qs = single.variants ? [...single.variants].sort((a, b) => a.h - b.h).map((v) => v.label) : null;
    const sig = JSON.stringify([qs, single.opts, single.varError, single.varLoading]);
    if (single.optsEl.dataset.sig !== sig) {
      single.optsEl.dataset.sig = sig;
      single.optsEl.innerHTML =
        `<div class="xhdl-qrow">${qs ? qs.map((l) => chipHtml('quality', l, l, l === single.opts.quality)).join('')
          : `<span class="xhdl-chip muted">${single.varError ? 'Qualities unavailable' : 'Loading…'}</span>`}</div>` +
        `<div class="xhdl-frow">${chipHtml('format', 'native', 'Native', single.opts.format === 'native')}${chipHtml('format', 'mkv', 'MKV', single.opts.format === 'mkv')}</div>`;
    }
    const est = estSize(single.variants, single.opts.quality, window.initials?.videoModel?.duration);
    single.sizeEl.textContent = est ? `≈ ${fmtSize(est)}` : '';
  }

  // Reads this page's own quality list (the same playlist the player loads).
  async function loadSingleVariants() {
    if (single.varLoading) return;
    single.varLoading = true;
    renderSingleOpts();
    try {
      single.variants = await getVariants(currentPageInfo().candidates);
      single.varError = '';
      single.opts.quality = chooseVariant(single.variants, single.opts.quality).label;
    } catch (e) {
      single.varError = e.message;
    }
    single.varLoading = false;
    renderSingleOpts();
  }

  async function startSingle() {
    const info = currentPageInfo();
    const q = {
      n: 1, key: info.vid, url: info.url, title: info.title, thumb: info.thumb, info,
      variants: single.variants, quality: single.opts.quality, format: single.opts.format,
      status: 'Preparing…', pct: 0, phase: 'busy', locked: true, ctrl: new AbortController(),
    };
    const panel = createPanel({ queue: [q], numbered: false, heading: 'Downloading video' });
    const baseUpdate = panel.update;
    panel.update = (item) => { baseUpdate(item); renderSingle(); };
    Object.assign(single, { q, ctrl: q.ctrl, running: true });
    guardUnload(1);
    renderSingle();

    let finished = false;
    panel.onMain = async () => {
      if (finished) { panel.close(); return; }
      if (q.status === 'Downloading' && q.pct < 1) {
        const choice = await panel.ask(q);
        if (choice !== 'delete' || finished) return;
        q.deleteAfter = true;
      }
      panel.setMain('Cancelling…', true);
      q.ctrl.abort();
    };

    let rest = 'Download';
    let ok = false;
    try {
      await runItem(q, panel);
      ok = true;
      rest = 'Saved ✓';
    } catch (e) {
      q.phase = 'idle';
      if (e.name === 'AbortError') {
        if (q.deleteAfter) {
          await Store.remove(q.jobId);
          q.status = 'Cancelled – deleted';
        } else {
          q.status = 'Cancelled – can resume';
          rest = (await Store.dir()) ? `Resume (${Math.floor((q.pct || 0) * 100)}%)` : 'Download';
        }
      } else {
        q.status = 'Failed';
        q.error = e.message;
        rest = 'Failed – retry';
        console.error('[XH-DL]', e);
      }
      panel.update(q);
    } finally {
      single.running = false;
      single.rest = rest;
      finished = true;
      guardUnload(-1);
      renderSingle();
      await panel.finish(ok ? 'Download complete' : q.status);
      if (ok) setTimeout(() => { if (!single.running) { single.rest = 'Download'; renderSingle(); } }, 4000);
    }
  }

  function buildSingleButton() {
    const host = document.querySelector('.controls__main-actions');
    if (!host || host.querySelector('[data-role="xh-dl"]')) return;
    const { btn, setText } = makeSiteButton(ICON_DOWNLOAD);
    single.setText = setText;
    btn.setAttribute('aria-label', 'Download video');
    btn.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (!single.running) startSingle(); // while running the button only shows progress
    });

    const wrap = document.createElement('div');
    wrap.className = 'controls__main-action';
    wrap.dataset.role = 'xh-dl';
    wrap.innerHTML = '<div class="xhdl-single"><div class="xhdl-single-left"><span class="xhdl-single-size"></span></div><div class="xhdl-single-opts"></div></div>';
    wrap.querySelector('.xhdl-single-left').prepend(btn);
    single.sizeEl = wrap.querySelector('.xhdl-single-size');
    single.optsEl = wrap.querySelector('.xhdl-single-opts');
    single.optsEl.addEventListener('click', (e) => {
      const chip = e.target.closest('.xhdl-chip[data-chip]');
      if (!chip || single.running) return;
      single.opts[chip.dataset.chip] = chip.dataset.v;
      renderSingleOpts();
    });

    const share = host.querySelector('[data-role="video-share-button"]');
    if (share) share.after(wrap); else host.appendChild(wrap);
    renderSingle();
    loadSingleVariants();
    partialFor(vidFromUrl(location.href)).then((p) => {
      if (p === undefined || single.running) return;
      single.rest = p === null ? 'Resume' : `Resume (${Math.floor(p * 100)}%)`;
      renderSingle();
    });
  }

  // Hide the site's own download control so only ours remains.
  function hideNativeDownload() {
    document.querySelectorAll('.controls a, .controls button, .player-container a, .player-container button, [data-role*="download" i]').forEach((n) => {
      if (n.closest('[data-role^="xh-dl"]') || n.dataset.xhdlHidden) return;
      const role = n.getAttribute('data-role') || '';
      const href = n.getAttribute('href') || '';
      const text = (n.textContent || '').trim().toLowerCase();
      if (/download/i.test(role) || /\/download(\/|$)/i.test(href) || text === 'download') {
        const target = n.closest('.controls__main-action') || n;
        if (target.closest('[data-role^="xh-dl"]')) return;
        target.style.display = 'none';
        n.dataset.xhdlHidden = '1';
      }
    });
  }

  // ======================= 10. batch selection =======================
  const sel = { mode: false, order: [], items: new Map() };
  const batch = { running: false, setText: null, cancelBtn: null, mainBtn: null };
  const RELATED = '[data-role="related-tabs-container"]';
  const listHasVideos = (el) => !!el?.querySelector('.thumb-list__item[data-video-type="video"] a[data-role="thumb-link"][href*="/videos/"]');
  // Where tiles can be picked: related videos on a video page, the main video list elsewhere (home, search, …).
  function batchScope() {
    if (IS_VIDEO_PAGE) return document.querySelector(RELATED);
    return [...document.querySelectorAll('[data-role="thumb-list-block"]')].find(listHasVideos) || null;
  }
  // Thumbnail URLs the page already lists (home and search keep them in window.initials).
  function listedThumb(id) {
    const i = window.initials || {};
    const lists = [i.searchResult?.videoThumbProps, i.layoutPage?.videoListProps?.videoThumbProps, i.videoListProps?.videoThumbProps];
    for (const l of lists) {
      const v = Array.isArray(l) && l.find((x) => String(x.id) === String(id));
      if (v) return v.imageURL || v.thumbURL || '';
    }
    return '';
  }

  function updateBatchLabel() {
    if (!batch.setText) return;
    const n = sel.order.length;
    // Cancel exists only while selecting. style.display is used because the site's button class overrides [hidden].
    if (batch.cancelBtn) batch.cancelBtn.style.display = sel.mode && !batch.running ? '' : 'none';
    if (batch.mainBtn) batch.mainBtn.classList.toggle('xhdl-dim', sel.mode && !n && !batch.running);
    if (batch.running) return batch.setText('Downloading…');
    if (sel.mode) return batch.setText(n === 1 ? 'Start Download (1)' : n ? `Start Downloads (${n})` : 'Start Downloads');
    batch.setText('Batch Download');
  }

  function setSelectMode(on) {
    sel.mode = on;
    if (!on) { sel.order = []; sel.items.clear(); }
    document.documentElement.classList.toggle('xhdl-select', on);
    decorate();
    refreshMarks();
    updateBatchLabel();
  }

  function decorate() {
    const root = batchScope();
    if (!root) return;
    let added = false;
    root.querySelectorAll('.thumb-list__item[data-video-id]').forEach((item) => {
      if (item.querySelector(':scope > .xhdl-check')) return;
      const a = item.querySelector('a[data-role="thumb-link"][href*="/videos/"]');
      if (!a) return;
      item.classList.add('xhdl-item');
      item.dataset.xhdlKey = vidFromUrl(a.href);
      const ring = document.createElement('div');
      ring.className = 'xhdl-ring';
      const chk = document.createElement('div');
      chk.className = 'xhdl-check';
      chk.innerHTML = '<span class="xhdl-box"></span><span class="xhdl-num"></span>';
      item.append(ring, chk);
      added = true;
    });
    if (added) refreshMarks();
  }

  function renderChips(el, it) {
    let box = el.querySelector(':scope > .xhdl-chips');
    if (!it) { box?.remove(); return; }
    if (!box) { box = document.createElement('div'); box.className = 'xhdl-chips'; el.appendChild(box); }
    const thumb = el.querySelector('a[data-role="thumb-link"]');
    const h = `${thumb?.offsetHeight || 0}px`;
    if (thumb && box.style.height !== h) box.style.height = h;
    const qs = it.variants ? [...it.variants].sort((a, b) => a.h - b.h).map((v) => v.label) : null;
    const size = estSize(it.variants, it.quality, it.info?.meta?.duration);
    const sig = JSON.stringify([qs, it.quality, it.format, it.error || '', size]);
    if (box.dataset.sig === sig) return;
    box.dataset.sig = sig;
    box.innerHTML =
      (size ? `<span class="xhdl-sizechip">≈ ${fmtSize(size)}</span>` : '') +
      `<div class="xhdl-frow">${chipHtml('format', 'native', 'Native', it.format === 'native')}${chipHtml('format', 'mkv', 'MKV', it.format === 'mkv')}</div>` +
      `<div class="xhdl-qrow">${qs ? qs.map((l) => chipHtml('quality', l, l, l === it.quality)).join('') : `<span class="xhdl-chip muted">${it.error ? 'Unavailable' : 'Loading…'}</span>`}</div>`;
  }

  function refreshMarks() {
    document.querySelectorAll('.xhdl-item').forEach((el) => {
      const key = el.dataset.xhdlKey;
      const i = sel.order.indexOf(key);
      el.classList.toggle('xhdl-on', i >= 0);
      const num = el.querySelector('.xhdl-num');
      const text = i >= 0 ? String(i + 1) : '';
      if (num && num.textContent !== text) num.textContent = text;
      renderChips(el, i >= 0 ? sel.items.get(key) : null);
    });
  }

  function toggleItem(item) {
    const key = item.dataset.xhdlKey;
    if (sel.items.has(key)) {
      sel.items.delete(key);
      sel.order = sel.order.filter((k) => k !== key);
    } else {
      const a = item.querySelector('a[data-role="thumb-link"]');
      const img = item.querySelector('img');
      const it = {
        key, url: a.href,
        title: a.getAttribute('aria-label') || a.title || img?.alt || key,
        thumb: img?.currentSrc || img?.src || listedThumb(item.dataset.videoId),
        quality: settings.quality, format: settings.format,
      };
      // Only network activity during selection: this video's page + its quality list.
      it.loadPromise = (async () => {
        try {
          it.info = await fetchVideoInfo(it.url);
          it.variants = await getVariants(it.info.candidates);
          it.quality = chooseVariant(it.variants, it.quality).label;
          return it;
        } catch (e) {
          it.error = e.message;
          log('could not read qualities for', it.url, e.message);
          return null;
        } finally {
          refreshMarks();
        }
      })();
      sel.items.set(key, it);
      sel.order.push(key);
    }
    refreshMarks();
    updateBatchLabel();
  }

  document.addEventListener('click', (e) => {
    if (!sel.mode || !(e.target instanceof Element) || e.button !== 0) return;
    const item = e.target.closest('.thumb-list__item');
    if (!item || !batchScope()?.contains(item)) return;
    e.preventDefault();
    e.stopImmediatePropagation();
    if (!item.classList.contains('xhdl-item')) return;
    const chip = e.target.closest('.xhdl-chip[data-chip]');
    if (chip) {
      const it = sel.items.get(item.dataset.xhdlKey);
      if (it) { it[chip.dataset.chip] = chip.dataset.v; refreshMarks(); }
      return;
    }
    toggleItem(item);
  }, true);

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && sel.mode && !batch.running) setSelectMode(false);
  });

  // ======================= 11. batch runner =======================
  async function runBatch() {
    const queue = sel.order.map((key, i) => {
      const it = sel.items.get(key);
      return {
        n: i + 1, key, url: it.url, title: it.title, thumb: it.thumb,
        info: it.info, variants: it.variants, infoError: !!it.error,
        quality: it.quality, format: it.format, loadPromise: it.loadPromise,
        status: 'Queued', pct: 0, bytes: 0, phase: 'idle', locked: false,
      };
    });
    setSelectMode(false);
    batch.running = true;
    updateBatchLabel();
    guardUnload(1);

    const panel = createPanel({ queue, numbered: true, heading: `Downloading ${queue.length} video${queue.length > 1 ? 's' : ''}` });
    for (const q of queue) {
      if (!q.variants && q.loadPromise) {
        q.loadPromise.then((it) => {
          if (q.locked) return;
          if (it) { q.info = it.info; q.variants = it.variants; q.quality = it.quality; } else q.infoError = true;
          panel.update(q);
        });
      }
    }

    let cancelled = false;
    let finished = false;
    let current = null;

    const startPreparing = (q) => {
      if (!q || q.locked || cancelled) return;
      q.locked = true;
      q.status = 'Preparing…';
      q.phase = 'busy';
      q.ctrl = new AbortController();
      panel.update(q);
      q.prep = prepare(q, q.ctrl.signal).then(() => panel.update(q)).catch((e) => { q.prepErr = e; });
    };

    panel.onMain = async () => {
      if (finished) { panel.close(); return; }
      if (cancelled) return;
      if (current && current.status === 'Downloading' && current.pct < 1) {
        const choice = await panel.ask(current);
        if (choice !== 'delete' || finished) return;
        if (current && current.status === 'Downloading') current.deleteAfter = true;
      }
      cancelled = true;
      panel.setMain('Cancelling…', true);
      for (const q of queue) if (q.ctrl && q.phase === 'busy') q.ctrl.abort();
    };

    for (let i = 0; i < queue.length; i++) {
      const q = queue[i];
      if (cancelled) {
        q.ctrl?.abort();
        q.status = 'Cancelled';
        q.phase = 'idle';
        panel.update(q);
        continue;
      }
      current = q;
      const next = queue[i + 1];
      startPreparing(q);
      q.onProgress = (p) => { if (p >= PREPARE_AT) startPreparing(next); };
      try {
        await q.prep;
        if (q.prepErr) throw q.prepErr;
        await runItem(q, panel);
        startPreparing(next);
      } catch (e) {
        q.phase = 'idle';
        if (e.name === 'AbortError') {
          q.status = q.deleteAfter ? 'Cancelled – deleted' : 'Cancelled';
          if (q.deleteAfter) await Store.remove(q.jobId);
        } else {
          q.status = 'Failed';
          q.error = e.message;
          console.error('[XH-DL]', q.url, e);
        }
      }
      panel.update(q);
    }

    current = null;
    finished = true;
    batch.running = false;
    guardUnload(-1);
    updateBatchLabel();
    const ok = queue.filter((q) => q.phase === 'done').length;
    await panel.finish(ok === queue.length ? 'All downloads complete' : `Finished – ${ok} of ${queue.length} saved`);
  }

  // Keeps the batch controls on screen once their original spot scrolls above the top edge.
  function makeSticky(wrap) {
    const ph = document.createElement('div');
    ph.className = 'xhdl-batch-ph';
    wrap.before(ph);
    const update = () => {
      if (!ph.isConnected) return;
      const r = ph.getBoundingClientRect();
      const pinned = wrap.classList.contains('xhdl-pinned');
      if (r.top < 8) {
        if (!pinned) {
          ph.style.width = `${wrap.offsetWidth}px`;
          ph.style.height = `${wrap.offsetHeight}px`;
          wrap.classList.add('xhdl-pinned');
        }
        wrap.style.left = `${r.left}px`;
      } else if (pinned) {
        wrap.classList.remove('xhdl-pinned');
        wrap.style.left = '';
        ph.style.width = ph.style.height = '0px';
      }
    };
    window.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    update();
  }

  function buildBatchButtons() {
    if (document.querySelector('[data-role="xh-dl-batch"]')) return;
    let place;
    if (IS_VIDEO_PAGE) {
      const tablist = document.querySelector('[data-role="related-with-tabs-container"] [role="tablist"]');
      if (!tablist) return;
      place = (wrap) => tablist.after(wrap);
    } else {
      // List pages: wait until the page has fully loaded and settled, then add a bar at the top
      // right of the video list (outside the list itself).
      if (document.readyState !== 'complete') return;
      const list = batchScope();
      if (!list) return;
      place = (wrap) => {
        const bar = document.createElement('div');
        bar.className = 'xhdl-listbar';
        bar.dataset.role = 'xh-dl-listbar';
        bar.appendChild(wrap);
        list.before(bar);
      };
    }
    const main = makeSiteButton(ICON_LIST);
    batch.setText = main.setText;
    main.btn.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (batch.running) return;
      if (!sel.mode) return setSelectMode(true);
      if (!sel.order.length) return; // nothing selected yet; use Cancel to leave selection
      runBatch();
    });
    batch.mainBtn = main.btn;

    const cancel = makeSiteButton(ICON_CLOSE);
    cancel.setText('Cancel');
    cancel.btn.style.display = 'none';
    batch.cancelBtn = cancel.btn;
    cancel.btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setSelectMode(false); });

    const gear = makeSiteButton(ICON_GEAR, '0 0 24 24');
    gear.setText('');
    gear.btn.setAttribute('aria-label', 'Download settings');
    gear.btn.title = 'Download settings';
    gear.btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openSettings(); });

    const wrap = document.createElement('div');
    wrap.className = 'xhdl-batch-wrap';
    wrap.dataset.role = 'xh-dl-batch';
    wrap.append(main.btn, cancel.btn, gear.btn);
    place(wrap);
    makeSticky(wrap);
    updateBatchLabel();
  }

  // ======================= 12. settings dialog =======================
  function openSettings() {
    const wrap = document.createElement('div');
    wrap.className = 'xhdl-backdrop';
    wrap.innerHTML = `
      <div class="xhdl-modal xhdl-settings" role="dialog" aria-modal="true">
        <h3 class="xhdl-title">Download settings</h3>
        <label class="xhdl-field"><span>Default quality</span>
          <select class="xhdl-sel" data-k="quality">${QUALITY_OPTIONS.map(([v, t]) => `<option value="${v}">${t}</option>`).join('')}</select></label>
        <p class="xhdl-sub">If the chosen quality isn't offered for a video, the next lower one is used.</p>
        <label class="xhdl-field"><span>Default file type</span>
          <select class="xhdl-sel" data-k="format"><option value="mkv">MKV (recommended)</option><option value="native">Native (MP4)</option></select></label>
        <div class="xhdl-info">
          <b>Why MKV?</b> An MKV file keeps everything for a video together in one file:
          <ul>
            <li><b>Subtitles</b>: English (always listed first and on by default) plus the original-language track, which you can switch between in your player.</li>
            <li><b>Metadata</b>: title, uploader, performers, categories, tags, description, upload date, views, likes, a 0–5 star rating, the creator's page (URL) and the video's page (Promotion URL).</li>
            <li><b>Cover art</b>: the video's thumbnail, shown by players and media servers (Plex, Jellyfin, Kodi).</li>
            <li>The same video and audio quality as Native; nothing is re-encoded.</li>
          </ul>
          MKV plays in VLC, mpv, MPC-HC, Kodi and most TV and media-server apps. <b>Native</b> saves the stream as a plain MP4 with no subtitles or tags; choose it if you need to play files in a web browser or a phone's gallery app.
        </div>
        <div class="xhdl-foot">
          <button type="button" class="xhdl-btn" data-act="cancel">Cancel</button>
          <button type="button" class="xhdl-btn xhdl-main" data-act="save">Save</button>
        </div>
      </div>`;
    document.body.appendChild(wrap);
    wrap.querySelector('[data-k="quality"]').value = settings.quality;
    wrap.querySelector('[data-k="format"]').value = settings.format;
    wrap.addEventListener('click', (e) => {
      const act = e.target.closest('[data-act]')?.dataset.act;
      if (e.target === wrap || act === 'cancel') wrap.remove();
      if (act === 'save') {
        settings = {
          quality: wrap.querySelector('[data-k="quality"]').value,
          format: wrap.querySelector('[data-k="format"]').value,
        };
        saveSettings(settings);
        if (!single.running) {
          single.opts = { quality: settings.quality, format: settings.format };
          if (single.variants) single.opts.quality = chooseVariant(single.variants, settings.quality).label;
          renderSingleOpts();
        }
        wrap.remove();
      }
    });
  }

  // ======================= 13. styles =======================
  const CSS = `
  html.xhdl-select .xhdl-item { cursor: pointer; }
  .xhdl-listbar { display: flex; justify-content: flex-end; align-items: center; margin: 0 0 10px; }
  .xhdl-listbar .xhdl-batch-wrap { margin-left: 0; }
  .xhdl-item { position: relative; }
  .xhdl-check { position: absolute; top: 8px; left: 8px; z-index: 31; display: none; gap: 6px; align-items: center; pointer-events: none; }
  html.xhdl-select .xhdl-check { display: flex; }
  .xhdl-box { width: 22px; height: 22px; box-sizing: border-box; border: 2px solid #fff; border-radius: 5px; background: rgba(0,0,0,.45);
    box-shadow: 0 0 0 1px rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; transition: background .15s; }
  .xhdl-on .xhdl-box { background: #22c55e; }
  .xhdl-on .xhdl-box::after { content: ""; width: 5px; height: 10px; border: solid #fff; border-width: 0 2px 2px 0; transform: translateY(-1px) rotate(45deg); }
  .xhdl-num { display: none; min-width: 22px; height: 22px; box-sizing: border-box; padding: 0 5px; background: #fff; color: #000;
    border: 2px solid #000; border-radius: 3px; font: 700 12px/18px system-ui, sans-serif; text-align: center; }
  .xhdl-on .xhdl-num { display: block; }
  .xhdl-ring { display: none; position: absolute; inset: -4px; border: 3px solid #fff; border-radius: 10px; pointer-events: none; z-index: 29;
    box-shadow: 0 0 12px 2px rgba(255,255,255,.7), 0 0 0 1px rgba(0,0,0,.35); animation: xhdl-pulse 1.6s ease-in-out infinite; }
  html.xhdl-select .xhdl-on .xhdl-ring { display: block; }
  @keyframes xhdl-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
  .xhdl-chips { position: absolute; top: 0; left: 0; right: 0; z-index: 30; pointer-events: none; display: none; }
  html.xhdl-select .xhdl-chips { display: block; }
  .xhdl-chips .xhdl-frow { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; }
  .xhdl-chips .xhdl-qrow { position: absolute; left: 6px; right: 6px; bottom: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
  .xhdl-chip, .xhdl-sizechip { padding: 1px 6px; background: #fff; color: #000; border: 2px solid #000; border-radius: 3px;
    font: 700 11px/16px system-ui, sans-serif; white-space: nowrap; }
  .xhdl-chip { pointer-events: auto; cursor: pointer; }
  .xhdl-chip.on { background: #22c55e; }
  .xhdl-chip.muted { cursor: default; font-weight: 600; }
  .xhdl-sizechip { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); pointer-events: none; }

  .xhdl-single { display: flex; align-items: flex-start; gap: 10px; }
  .xhdl-single-left { display: flex; flex-direction: column; align-items: center; gap: 3px; }
  .xhdl-single-size { font: 600 12px/1.2 system-ui, sans-serif; opacity: .85; min-height: 14px; }
  .xhdl-single-opts { display: flex; flex-direction: column; gap: 4px; }
  .xhdl-single-opts .xhdl-qrow, .xhdl-single-opts .xhdl-frow { display: flex; flex-wrap: wrap; gap: 4px; }

  .xhdl-batch-wrap { display: flex; align-items: center; gap: 6px; margin-left: 12px; }
  .xhdl-batch-ph { flex: none; width: 0; height: 0; }
  .xhdl-dim { opacity: .55; cursor: default; }
  .xhdl-batch-wrap.xhdl-pinned { position: fixed; top: 8px; z-index: 2147482000; margin: 0; padding: 6px; border-radius: 10px;
    background: rgba(0,0,0,.6); box-shadow: 0 6px 20px rgba(0,0,0,.35); }

  .xhdl-backdrop { position: fixed; inset: 0; z-index: 2147483000; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; }
  .xhdl-modal { --bg: #fff; --fg: #141414; --muted: #666; --line: #d9d9d9; --glass: rgba(255,255,255,.72); --fill: 0%;
    width: min(1060px, 96vw); max-height: 86vh; display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; padding: 20px;
    color: var(--fg); border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.45);
    background: linear-gradient(90deg, #22c55e 0%, #16a34a var(--fill), transparent var(--fill)), var(--bg);
    font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
  html.dark .xhdl-modal { --bg: #1e1e1e; --fg: #f1f1f1; --muted: #b0b0b0; --line: #444; --glass: rgba(24,24,24,.72); }
  .xhdl-title { margin: 0; font-size: 18px; text-align: center; padding: 6px 12px; border-radius: 8px; background: var(--glass); align-self: center; }
  .xhdl-warn { margin: 0; padding: 8px 12px; border-radius: 8px; background: var(--glass); border-left: 4px solid #f59e0b; }
  .xhdl-tablewrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--glass); }
  .xhdl-table { width: 100%; border-collapse: collapse; }
  .xhdl-table th, .xhdl-table td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; text-align: center; }
  .xhdl-table th { font-weight: 600; color: var(--muted); }
  .xhdl-table tr:last-child td { border-bottom: 0; }
  .xhdl-table .c-n { font-weight: 700; width: 28px; }
  .xhdl-table .c-name { text-align: left; max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .xhdl-table .c-status, .xhdl-table .c-link, .xhdl-table .c-size { white-space: nowrap; }
  .xhdl-link { color: inherit; text-decoration: underline; font-weight: 600; }
  .c-status[data-state="done"] { color: #15803d; font-weight: 700; }
  html.dark .c-status[data-state="done"] { color: #4ade80; }
  .c-status[data-state="failed"] { color: #dc2626; font-weight: 600; }
  .c-status[data-state="cancelled"] { color: var(--muted); }
  .xhdl-hover { display: inline-block; padding: 6px 12px; border: 1px dashed var(--muted); border-radius: 6px; color: var(--muted); font-size: 12px; cursor: default; }
  .xhdl-preview { position: fixed; z-index: 2147483600; display: none; width: 320px; height: auto; border-radius: 8px; border: 2px solid #fff;
    box-shadow: 0 10px 30px rgba(0,0,0,.5); pointer-events: none; background: #000; }
  .xhdl-sel { background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; font: inherit; }
  .xhdl-sel:disabled { opacity: .7; }
  .c-prog { white-space: nowrap; }
  .xhdl-progwrap { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; }
  .xhdl-progwrap span { font-size: 12px; line-height: 1; }
  .xhdl-bar { display: block; width: 90px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
  .xhdl-bar i { display: block; height: 100%; width: 0; background: #22c55e; transition: width .2s; }
  .xhdl-confirm { padding: 12px; border: 1px solid #dc2626; border-radius: 8px; background: var(--bg); }
  .xhdl-confirm p { margin: 0 0 10px; }
  .xhdl-confirm-btns { display: flex; gap: 8px; justify-content: flex-end; }
  .xhdl-foot { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
  .xhdl-cache { margin-right: auto; font-size: 13px; padding: 2px 8px; border-radius: 6px; background: var(--glass); }
  .xhdl-cache:empty { display: none; }
  .xhdl-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--fg);
    font: 600 14px system-ui, sans-serif; cursor: pointer; }
  .xhdl-btn:hover:not(:disabled) { border-color: var(--fg); }
  .xhdl-btn:disabled { opacity: .5; cursor: default; }
  .xhdl-main { background: var(--fg); color: var(--bg); border-color: var(--fg); }
  .xhdl-danger { border-color: #dc2626; color: #dc2626; }
  .xhdl-settings { width: min(560px, 94vw); background: var(--bg); }
  .xhdl-settings .xhdl-title { background: none; }
  .xhdl-field { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-weight: 600; }
  .xhdl-sub { margin: -6px 0 0; color: var(--muted); font-size: 12px; }
  .xhdl-info { padding: 10px 14px; border-radius: 8px; background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.45); font-size: 13px; }
  .xhdl-info ul { margin: 6px 0; padding-left: 18px; }
  `;

  // ======================= 14. bootstrap =======================
  let scheduled = false;
  function tick() {
    scheduled = false;
    if (IS_VIDEO_PAGE) { buildSingleButton(); hideNativeDownload(); }
    buildBatchButtons();
    if (sel.mode) decorate();
    if (single.optsEl && !single.variants && !single.varLoading && single.varError && seen.size) loadSingleVariants();
  }

  function start() {
    const style = document.createElement('style');
    style.textContent = CSS;
    document.head.appendChild(style);
    tick();
    new MutationObserver(() => {
      if (!scheduled) { scheduled = true; requestAnimationFrame(tick); }
    }).observe(document.body, { childList: true, subtree: true });
    janitor().catch((e) => log('cleanup error', e));
  }

  window.addEventListener('load', () => setTimeout(() => { if (!scheduled) { scheduled = true; requestAnimationFrame(tick); } }, 300));
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();

  window.__xhdl = { cacheInfo, clearCache, janitor, settings: () => settings };
})();