☰

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 यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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 };
})();