☰

Hanime1 Download Manager

Replace download links with a native-style quality menu, batch download mode, and in-page downloads without leaving the page.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Hanime1 Download Manager
// @namespace    Violentmonkey Scripts
// @version      1.0.2
// @author       OG-Open-Source
// @description  Replace download links with a native-style quality menu, batch download mode, and in-page downloads without leaving the page.
// @icon         https://vdownload.hembed.com/image/icon/nav_logo.png?secure=HxkFdqiVxMMXXjau9riwGg==,4855471889
// @match        *://hanime1.com/*
// @match        *://hanime1.me/*
// @match        *://hanimeone.com/*
// @match        *://hanimeone.me/*
// @match        https://og-open-source.github.io/UserScripts/
// @match        https://og-open-source.github.io/UserScripts/settings/*
// @connect      hanime1.com
// @connect      hanime1.me
// @connect      hanimeone.com
// @connect      hanimeone.me
// @connect      vdownload.hembed.com
// @connect      raw.githubusercontent.com
// @connect      localhost
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  var _GM_download = /* @__PURE__ */ (() => typeof GM_download != "undefined" ? GM_download : void 0)();
  var _GM_getValue = /* @__PURE__ */ (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
  var _GM_registerMenuCommand = /* @__PURE__ */ (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : void 0)();
  var _GM_setValue = /* @__PURE__ */ (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
  var _GM_xmlhttpRequest = /* @__PURE__ */ (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
  let api = null;
  function configureGmApi(gm) {
    api = gm;
  }
  function getApi() {
    if (!api) {
      throw new Error(
        "[@userscripts/shared] GM API not configured — call configureGmApi() at script entry."
      );
    }
    return api;
  }
  function gmFetch(url, timeout = 2e4) {
    return new Promise((resolve, reject) => {
      const details = {
        method: "GET",
        url,
        timeout,
        onload: (res) => {
          if (res.status >= 200 && res.status < 400) resolve(res.responseText);
          else reject(new Error("HTTP " + res.status));
        },
        onerror: () => reject(new Error("network error")),
        ontimeout: () => reject(new Error("timeout"))
      };
      getApi().GM_xmlhttpRequest(details);
    });
  }
  function gmGet(key, defaultValue) {
    return getApi().GM_getValue(key, defaultValue);
  }
  function gmSet(key, value) {
    getApi().GM_setValue(key, value);
  }
  function gmMenuCommand(caption, onClick) {
    getApi().GM_registerMenuCommand(caption, onClick);
  }
  const MANAGER_URL = "https://og-open-source.github.io/UserScripts";
  const CACHE_KEY = (scriptId) => `i18n:${scriptId}`;
  async function loadLocales(source, onChange) {
    const key = CACHE_KEY(source.scriptId);
    const cached = gmGet(key, null);
    const fresh = gmFetch(source.url).then((body) => JSON.parse(body));
    if (cached) {
      void fresh.then((next2) => {
        if (JSON.stringify(cached) !== JSON.stringify(next2)) {
          gmSet(key, next2);
          onChange?.();
        }
      });
      return cached;
    }
    const next = await fresh;
    gmSet(key, next);
    return next;
  }
  async function createI18n(options) {
    const { source, fallback, storageKey = "lang" } = options;
    const preferred = gmGet(storageKey, null) ?? detectLang();
    let file = {
      id: source.scriptId,
      default: "en-US",
      languages: [{ code: "en-US", name: "English (United States)" }]
    };
    try {
      file = await loadLocales(source, () => location.reload());
    } catch {
    }
    const known = file.languages.map((item) => item.code);
    const lang = known.includes(preferred) ? preferred : file.default;
    const entry = file.languages.find((item) => item.code === lang);
    const t = (key) => {
      if (lang === file.default) return fallback[key] ?? key;
      return entry?.messages?.[key] ?? fallback[key] ?? key;
    };
    const current = file.languages.find((item) => item.code === lang);
    gmMenuCommand(`Language: ${current?.name ?? lang} (${lang})`, () => {
      window.open(`${MANAGER_URL}/settings/language/`, "_blank");
    });
    return { lang, languages: file.languages, t };
  }
  function detectLang() {
    const page = (document.documentElement.lang || "en").toLowerCase();
    if (page.startsWith("zh")) {
      const traditional = page.includes("hant") || page.includes("tw") || page.includes("hk") || page.includes("mo");
      return traditional ? "zh-Hant-TW" : "zh-Hans-CN";
    }
    if (page === "en" || page.startsWith("en-")) return "en-US";
    return "en-US";
  }
  const REGISTRY_ELEMENT_ID = "userscript-registry";
  const PRESENCE_EVENT = "userscript:presence";
  const PRESENCE_GLOBAL = "__userscriptPresence";
  function all() {
    const root = globalThis;
    if (!root[PRESENCE_GLOBAL]) root[PRESENCE_GLOBAL] = [];
    return root[PRESENCE_GLOBAL];
  }
  function publishPresence(presence) {
    const list = all();
    if (!list.some((item) => item.id === presence.id)) list.push(presence);
    let el = document.getElementById(REGISTRY_ELEMENT_ID);
    if (!el) {
      el = document.createElement("script");
      el.type = "application/json";
      el.id = REGISTRY_ELEMENT_ID;
      document.head.append(el);
    }
    el.textContent = JSON.stringify(list);
    window.dispatchEvent(new CustomEvent(PRESENCE_EVENT));
  }
  function probeTheme(options) {
    const { panelClass, actionClass, itemHover = "hsla(0,0%,100%,.2)" } = options;
    const probe = document.createElement("div");
    probe.className = panelClass;
    probe.style.cssText = "position:fixed;left:-9999px;top:-9999px;visibility:hidden;z-index:-1;display:block!important;opacity:1";
    document.body.appendChild(probe);
    probe.offsetLeft;
    const cs = getComputedStyle(probe);
    const theme = {
      panelBg: cs.backgroundColor,
      panelColor: cs.color,
      itemHover,
      actionBg: "rgb(0,0,0)"
    };
    probe.remove();
    if (actionClass) {
      const probe2 = document.createElement("div");
      probe2.className = actionClass;
      probe2.style.cssText = "position:fixed;left:-9999px;top:-9999px;visibility:hidden;z-index:-1;display:inline-block";
      document.body.appendChild(probe2);
      probe2.offsetLeft;
      theme.actionBg = getComputedStyle(probe2).backgroundColor;
      probe2.remove();
    } else {
      theme.actionBg = "rgb(0,0,0)";
    }
    return theme;
  }
  const DL_PATH = "/download?v=";
  const qualityCache = /* @__PURE__ */ new Map();
  function queryQualities(videoId) {
    if (qualityCache.has(videoId)) {
      return Promise.resolve(qualityCache.get(videoId));
    }
    return gmFetch(location.origin + DL_PATH + videoId).then((html) => {
      const doc = new DOMParser().parseFromString(html, "text/html");
      const list = [];
      doc.querySelectorAll("table.download-table tbody tr").forEach((tr) => {
        const link = tr.querySelector("a[data-url]");
        if (!link) return;
        const m = /(\d{3,4}p)/.exec(tr.textContent ?? "");
        if (!m) return;
        list.push({
          quality: m[1],
          url: link.dataset.url,
          filename: link.getAttribute("download")
        });
      });
      qualityCache.set(videoId, list);
      return list;
    });
  }
  function extractVideoId(node) {
    const href = typeof node === "string" ? node : node?.getAttribute?.("dataset" in node ? "data-href" : "href") ?? node?.getAttribute?.("href") ?? null;
    if (href) {
      const m = /[?&]v=([^&]+)/.exec(href);
      if (m) return m[1];
    }
    if (node && typeof node === "object" && "querySelector" in node) {
      const link = node.querySelector('a[href*="watch?v="]');
      if (link) {
        const m = /[?&]v=([^&]+)/.exec(link.getAttribute("href") ?? "");
        if (m) return m[1];
      }
    }
    return null;
  }
  const MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
    "mp4",
    "webm",
    "mkv",
    "m4v",
    "mov",
    "m3u8",
    "mp3",
    "jpg",
    "jpeg",
    "png",
    "webp",
    "gif"
  ]);
  function downloadName(filename, url) {
    const fromUrl = extensionOf(url);
    const trimmed = filename?.trim() ?? "";
    if (!trimmed) return fromUrl ? `video.${fromUrl}` : "video.mp4";
    if (hasExtension(trimmed)) return trimmed;
    return `${trimmed}.${fromUrl ?? "mp4"}`;
  }
  function extensionOf(url) {
    const path = url.split(/[?#]/, 1)[0] ?? "";
    const base = path.slice(path.lastIndexOf("/") + 1);
    const dot = base.lastIndexOf(".");
    if (dot <= 0 || dot === base.length - 1) return null;
    const ext = base.slice(dot + 1).toLowerCase();
    return MEDIA_EXTENSIONS.has(ext) ? ext : null;
  }
  function hasExtension(name) {
    const base = name.slice(name.lastIndexOf("/") + 1);
    const dot = base.lastIndexOf(".");
    if (dot <= 0 || dot === base.length - 1) return false;
    return MEDIA_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
  }
  async function resolveWithFallback(videoId, quality, qualities) {
    const list = await queryQualities(videoId);
    const idx = qualities.indexOf(quality);
    for (let i = idx; i < qualities.length; i++) {
      const info = list.find((q) => q.quality === qualities[i]);
      if (info) return { info, quality: qualities[i] };
    }
    return null;
  }
  const QUALITIES = ["1080p", "720p", "480p"];
  function createBatch(t, menuAskQuality, onActiveChange) {
    const batchState = /* @__PURE__ */ new Set();
    let batchBar = null;
    let batchOn = false;
    let batchQuality = null;
    const BATCH_BAR_STYLE = [
      "position:fixed",
      "right:0",
      "top:50%",
      "transform:translateY(-50%)",
      "z-index:2147483646",
      "display:flex",
      "flex-direction:column",
      "align-items:center",
      "gap:6px",
      "padding:8px 6px",
      "background-color:var(--h1dl-panel-bg)",
      "-webkit-backdrop-filter:blur(8px)",
      "backdrop-filter:blur(8px)",
      "border-radius:8px 0 0 8px",
      "box-shadow:0 4px 24px rgba(0,0,0,0.5)",
      "color:var(--h1dl-panel-color)",
      "font-size:12px",
      "max-height:70vh"
    ].join(";");
    function buildBatchBar() {
      if (batchBar) return batchBar;
      const bar = document.createElement("div");
      bar.className = "h1dl-batch-bar";
      bar.style.cssText = BATCH_BAR_STYLE;
      const count = document.createElement("div");
      count.className = "h1dl-bb-count";
      count.style.cssText = "font-size:13px;font-weight:bold;line-height:1.2";
      count.textContent = "0";
      bar.appendChild(count);
      const divider1 = document.createElement("div");
      divider1.style.cssText = "width:70%;height:1px;background:var(--h1dl-item-hover);flex:none";
      bar.appendChild(divider1);
      const prog = document.createElement("div");
      prog.className = "h1dl-batch-prog";
      prog.style.cssText = [
        "display:flex",
        "flex-direction:column",
        "gap:4px",
        "align-items:center",
        "max-height:46vh",
        "overflow-y:auto",
        "scrollbar-width:thin",
        "padding:2px 0",
        "flex:0 1 auto",
        "min-height:0"
      ].join(";");
      bar.appendChild(prog);
      const divider2 = divider1.cloneNode();
      bar.appendChild(divider2);
      const mkBtn = (title, icon, onClick, extraClass) => {
        const b = document.createElement("button");
        b.className = "h1dl-bb-btn";
        if (extraClass) b.classList.add(extraClass);
        b.title = title;
        b.innerHTML = `<i class="material-icons">${icon}</i>`;
        b.addEventListener("click", onClick);
        return b;
      };
      const BP_MIN = {
        xs: 0,
        sm: 768,
        md: 992,
        lg: 1200
      };
      const BP_MAX = {
        xs: 768,
        sm: 992,
        md: 1200,
        lg: Infinity
      };
      const vw = () => window.innerWidth || document.documentElement.clientWidth || 1024;
      const isCardVisible = (card) => {
        let el = card;
        while (el && el !== document.body) {
          if (el.style?.display === "none") return false;
          const cls = el.classList;
          for (const bp of Object.keys(BP_MIN)) {
            if (cls.contains("hidden-" + bp)) {
              const w = vw();
              if (w >= BP_MIN[bp] && w < BP_MAX[bp]) return false;
            }
          }
          el = el.parentElement;
        }
        return true;
      };
      const selectAll = mkBtn(t("selectAll"), "select_all", () => {
        batchState.forEach((entry) => {
          if (!isCardVisible(entry.card)) return;
          if (entry.state !== "downloading" && entry.state !== "done") setChecked(entry, true);
        });
      });
      bar.appendChild(selectAll);
      const selectNone = mkBtn(t("selectNone"), "deselect", () => {
        batchState.forEach((entry) => {
          if (entry.state !== "downloading") setChecked(entry, false);
        });
      });
      bar.appendChild(selectNone);
      const dlBtn = mkBtn(
        t("downloadSelected"),
        "download",
        () => void startBatchDownload(),
        "h1dl-bb-dl"
      );
      bar.appendChild(dlBtn);
      document.body.appendChild(bar);
      batchBar = bar;
      return bar;
    }
    function updateTick(entry, state, percent) {
      const tick = entry.tick;
      if (!tick) return;
      const fill = tick.querySelector(".h1dl-batch-fill");
      tick.classList.remove("done", "error");
      if (state === "downloading") {
        fill.style.width = (percent || 0) + "%";
      } else if (state === "done") {
        tick.classList.add("done");
        fill.style.width = "100%";
      } else if (state === "error") {
        tick.classList.add("error");
        fill.style.width = "100%";
      } else {
        fill.style.width = "0%";
      }
    }
    function syncTicks() {
      if (!batchBar) return;
      const prog = batchBar.querySelector(".h1dl-batch-prog");
      const checked = Array.from(batchState).filter((e) => e.checked);
      checked.forEach((entry) => {
        if (!entry.tick) {
          const tick = document.createElement("div");
          tick.className = "h1dl-batch-tick";
          tick.innerHTML = '<div class="h1dl-batch-fill"></div><span class="h1dl-batch-name"></span>';
          const titleEl = entry.card.querySelector(".title, .video-title");
          const name = titleEl ? titleEl.textContent?.trim() ?? entry.videoId : entry.videoId;
          tick.querySelector(".h1dl-batch-name").textContent = name;
          tick.title = name;
          prog.appendChild(tick);
          entry.tick = tick;
          updateTick(entry, entry.state);
        }
      });
      Array.from(batchState).forEach((entry) => {
        if (!entry.checked && entry.tick) {
          entry.tick.remove();
          entry.tick = null;
        }
      });
    }
    function updateBatchBar() {
      if (!batchBar) return;
      const pending = Array.from(batchState).filter((e) => e.checked && e.state !== "done");
      batchBar.querySelector(".h1dl-bb-count").textContent = String(pending.length);
      const dlBtn = batchBar.querySelector(".h1dl-bb-dl");
      dlBtn.disabled = pending.length === 0 || pending.some((e) => e.state === "downloading");
      syncTicks();
    }
    function setChecked(entry, checked) {
      entry.checked = checked;
      entry.check.classList.toggle("checked", checked);
      entry.check.querySelector(".material-icons").textContent = checked ? "check" : "";
      updateBatchBar();
      if (!checked && batchOn && !Array.from(batchState).some((e) => e.checked)) {
        setBatchMode(false);
      }
    }
    function setBatchMode(on) {
      batchOn = on;
      document.body.classList.toggle("h1dl-batch-on", on);
      if (on) {
        buildBatchBar();
        batchBar.style.display = "flex";
      } else if (batchBar) {
        batchBar.style.display = "none";
      }
    }
    async function startBatchDownload() {
      const selected = Array.from(batchState).filter((e) => e.checked && e.state !== "done");
      if (!selected.length) return;
      if (!batchQuality) {
        const q = await menuAskQuality(QUALITIES);
        if (!q) return;
        batchQuality = q;
      }
      const seenIds = /* @__PURE__ */ new Set();
      const downgraded = [];
      for (const entry of selected) {
        if (seenIds.has(entry.videoId)) {
          entry.state = "done";
          updateTick(entry, "done");
          entry.check.classList.add("completed");
          continue;
        }
        seenIds.add(entry.videoId);
        entry.state = "downloading";
        updateTick(entry, "downloading", 0);
        onActiveChange(1);
        const videoId = entry.videoId;
        try {
          const resolved = await resolveWithFallback(videoId, batchQuality, QUALITIES);
          if (!resolved) throw new Error("no download link found");
          if (resolved.quality !== batchQuality) {
            downgraded.push({ entry, from: batchQuality, to: resolved.quality });
          }
          await new Promise((resolve) => {
            _GM_download({
              url: resolved.info.url,
              name: downloadName(resolved.info.filename, resolved.info.url),
              onload: () => {
                onActiveChange(-1);
                entry.state = "done";
                updateTick(entry, "done");
                entry.check.classList.add("completed");
                resolve();
              },
              onerror: () => {
                onActiveChange(-1);
                entry.state = "error";
                updateTick(entry, "error");
                resolve();
              },
              ontimeout: () => {
                onActiveChange(-1);
                entry.state = "error";
                updateTick(entry, "error");
                resolve();
              },
              onprogress: (e) => {
                if (e?.lengthComputable) {
                  updateTick(entry, "downloading", Math.round(e.loaded / e.total * 100));
                }
              }
            });
          });
        } catch {
          onActiveChange(-1);
          entry.state = "error";
          updateTick(entry, "error");
        }
      }
      if (downgraded.length) {
        const names = downgraded.map((d) => {
          const titleEl = d.entry.card.querySelector(".title, .video-title");
          return (titleEl ? titleEl.textContent?.trim() : d.entry.videoId) + ` (${d.from}→${d.to})`;
        }).join("\n");
        console.warn("[h1dl] " + t("downgradeNotice") + "\n" + names);
      }
      batchQuality = null;
      updateBatchBar();
    }
    return {
      addEntry(card, videoId, check) {
        const entry = {
          card,
          check,
          videoId,
          checked: false,
          state: "idle",
          tick: null
        };
        batchState.add(entry);
        return entry;
      },
      setChecked,
      setMode: setBatchMode,
      isOn: () => batchOn,
      element: () => batchBar,
      update: updateBatchBar,
      startDownload: startBatchDownload
    };
  }
  function buildStates(t) {
    return {
      idle: { icon: "download", label: t("download") },
      fetching: { icon: "hourglass_empty", label: t("fetching"), spin: true },
      downloading: { icon: "cloud_download", label: "%" },
      done: { icon: "check_circle", label: t("done") },
      error: { icon: "error_outline", label: t("error") }
    };
  }
  function createStateMachine(t) {
    const STATES = buildStates(t);
    return function setButtonState(btn, state, percent) {
      const s = STATES[state] || STATES.idle;
      const icon = btn.querySelector(".h1dl-icon");
      const label = btn.querySelector(".h1dl-label");
      if (icon) {
        icon.textContent = s.icon;
        icon.classList.toggle("h1dl-spin", !!s.spin);
      }
      if (label) {
        label.textContent = state === "downloading" && percent != null ? percent + "%" : s.label;
      }
      btn.dataset.state = state;
    };
  }
  async function startDownload(btn, videoId, quality, setState, onActiveChange) {
    if (btn.dataset.state === "fetching" || btn.dataset.state === "downloading") return;
    setState(btn, "fetching");
    onActiveChange(1);
    try {
      const list = await queryQualities(videoId);
      const info = list.find((q) => q.quality === quality);
      if (!info) throw new Error("quality not found: " + quality);
      setState(btn, "downloading", 0);
      _GM_download({
        url: info.url,
        name: downloadName(info.filename, info.url),
        onload: () => {
          onActiveChange(-1);
          setState(btn, "done");
        },
        onerror: () => {
          onActiveChange(-1);
          setState(btn, "error");
        },
        ontimeout: () => {
          onActiveChange(-1);
          setState(btn, "error");
        },
        onprogress: (e) => {
          if (e?.lengthComputable) {
            setState(btn, "downloading", Math.round(e.loaded / e.total * 100));
          }
        }
      });
    } catch (err) {
      onActiveChange(-1);
      console.warn("[h1dl] download failed:", err?.message);
      setState(btn, "error");
    }
  }
  const MESSAGES = {
    download: "Download",
    fetching: "Resolving",
    done: "Done",
    error: "Failed",
    loadingQualities: "Loading qualities…",
    noQualities: "No downloadable quality",
    selectAll: "Select all",
    selectNone: "Select none",
    downloadSelected: "Download selected",
    chooseBatchQuality: "Choose batch quality (auto-downgrade if unavailable)",
    downgradeNotice: "Quality downgraded:"
  };
  function createInject(t, menu, batch, setState) {
    function buildListButton(videoId) {
      const btn = document.createElement("div");
      btn.className = "h1dl-list-btn";
      btn.setAttribute("title", t("download"));
      btn.innerHTML = '<i class="material-icons h1dl-icon">download</i><span class="h1dl-label">' + t("download") + "</span>";
      btn.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        if (btn.dataset.state === "error") {
          setState(btn, "idle");
        }
        menu.open(btn, videoId);
      });
      return btn;
    }
    function injectWatchPage() {
      const downloadBtn = document.getElementById("downloadBtn");
      if (downloadBtn && !downloadBtn.dataset.h1dl) {
        const videoId = extractVideoId(location.href) || extractVideoId(downloadBtn);
        if (videoId) {
          downloadBtn.dataset.h1dl = "1";
          downloadBtn.removeAttribute("href");
          downloadBtn.style.cursor = "pointer";
          const icon = downloadBtn.querySelector("#video-download-btn");
          if (icon) icon.classList.add("h1dl-icon");
          const labelContainer = icon ? icon.parentElement : downloadBtn.querySelector(".video-show-action-btn");
          if (labelContainer) {
            Array.from(labelContainer.childNodes).forEach((node) => {
              if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
                const span = document.createElement("span");
                span.className = "h1dl-label";
                span.textContent = node.textContent.trim();
                node.replaceWith(span);
              }
            });
          }
          downloadBtn.addEventListener("click", (e) => {
            e.preventDefault();
            e.stopPropagation();
            menu.open(downloadBtn, videoId);
          });
        }
      }
      document.querySelectorAll("a.more-horiz-download-late").forEach((a) => {
        const anchor = a;
        if (anchor.dataset.h1dl) return;
        const videoId = extractVideoId(anchor) || extractVideoId(location.href);
        if (!videoId) return;
        anchor.dataset.h1dl = "1";
        anchor.removeAttribute("href");
        anchor.style.cursor = "pointer";
        anchor.addEventListener("click", (e) => {
          e.preventDefault();
          e.stopPropagation();
          const openDropdown = document.querySelector(".more-horiz-wrapper.open");
          if (openDropdown) openDropdown.classList.remove("open");
          menu.open(anchor, videoId);
        });
      });
    }
    function injectListings() {
      document.querySelectorAll(".video-item-container").forEach((card) => {
        const el = card;
        if (el.dataset.h1dlBtn) return;
        const videoId = extractVideoId(el);
        if (!videoId) return;
        el.dataset.h1dlBtn = "1";
        el.classList.add("h1dl-card");
        const btn = buildListButton(videoId);
        const metaData = el.querySelector(".video-meta-data");
        const subtitle = el.querySelector(".subtitle");
        if (metaData) {
          metaData.after(btn);
        } else if (subtitle) {
          subtitle.appendChild(btn);
        } else {
          el.appendChild(btn);
        }
        const check = document.createElement("div");
        check.className = "h1dl-check";
        check.innerHTML = '<i class="material-icons"></i>';
        const entry = batch.addEntry(el, videoId, check);
        check.addEventListener("click", (e) => {
          e.preventDefault();
          e.stopPropagation();
          if (entry.state === "done") return;
          if (!batch.isOn()) batch.setMode(true);
          batch.setChecked(entry, !entry.checked);
        });
        el.appendChild(check);
        el.dataset.h1dlEntry = "";
      });
      if (batch.isOn()) batch.update();
    }
    function scan() {
      if (location.pathname === "/watch") {
        injectWatchPage();
        injectListings();
      } else {
        injectListings();
      }
    }
    return { scan };
  }
  const NATIVE_MENU_CLASS = "more-horiz-panel dropdown-menu";
  const NATIVE_ITEM_CLASS = "more-horiz-item";
  function injectStyles(theme) {
    const style = document.createElement("style");
    style.textContent = `
:root {
  --h1dl-panel-bg: ${theme.panelBg};
  --h1dl-panel-color: ${theme.panelColor};
  --h1dl-item-hover: ${theme.itemHover};
  --h1dl-action-bg: ${theme.actionBg};
}

.h1dl-list-btn{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;background:rgba(0,0,0,.55);color:#e9e9e9;font-size:12px;line-height:1.6;cursor:pointer;user-select:none;opacity:0;transition:opacity .15s ease-in-out;white-space:nowrap;text-decoration:none}
.h1dl-list-btn .material-icons{font-size:16px;margin-top:-1px}
.h1dl-list-btn:hover{background:rgba(0,0,0,.8)}
.h1dl-batch-on .h1dl-list-btn,.playlist-hover-wrap:hover .h1dl-list-btn,.video-item-container:hover .h1dl-list-btn{opacity:1}
.h1dl-list-btn.h1dl-pinned{opacity:1!important}
.video-item-container .subtitle{position:relative}
.video-item-container .subtitle .h1dl-list-btn{position:absolute;right:0;top:50%;transform:translateY(-50%)}
.h1dl-card{position:relative}
.h1dl-check{position:absolute;top:6px;right:6px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.65);border:2px solid #e9e9e9;border-radius:4px;cursor:pointer;user-select:none;color:#fff;font-size:14px;line-height:1;opacity:0;transition:opacity .15s ease-in-out;z-index:5}
.h1dl-check .material-icons{font-size:14px}
.h1dl-batch-on .h1dl-check,.h1dl-card:hover .h1dl-check{opacity:1}
.h1dl-check.checked,.h1dl-check.checked .material-icons{background:#2196f3;border-color:#2196f3;color:#fff}
.h1dl-check.completed{opacity:.35!important;cursor:default;pointer-events:none}
.h1dl-batch-tick{position:relative;height:6px;width:20px;border-radius:2px;background:var(--h1dl-action-bg);overflow:visible;flex:none}
.h1dl-batch-tick .h1dl-batch-fill{position:absolute;left:0;top:0;bottom:0;background:#2196f3;width:0%;transition:width .2s linear;border-radius:inherit}
.h1dl-batch-tick.done .h1dl-batch-fill,.h1dl-batch-tick.error .h1dl-batch-fill{width:100%}
.h1dl-batch-tick.done .h1dl-batch-fill{background:#33CC55}
.h1dl-batch-tick.error .h1dl-batch-fill{background:#CC3333}
.h1dl-batch-name{display:none;position:absolute;right:calc(100% + 6px);top:50%;transform:translateY(-50%);height:24px;line-height:24px;padding:0 8px;background:var(--h1dl-panel-bg);color:var(--h1dl-panel-color);border-radius:4px;white-space:nowrap;font-size:12px;pointer-events:none;box-shadow:0 2px 10px rgba(0,0,0,.6)}
.h1dl-batch-tick:hover .h1dl-batch-name{display:block}
.h1dl-batch-prog{scrollbar-width:none;-ms-overflow-style:none}
.h1dl-batch-prog::-webkit-scrollbar{display:none}
.h1dl-batch-tick::after{content:"";position:absolute;right:100%;top:-6px;bottom:-6px;width:6px}
.h1dl-batch-bar .h1dl-bb-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:0 0;border:none;border-radius:4px;color:#e9e9e9;cursor:pointer;padding:0;flex:none}
.h1dl-batch-bar .h1dl-bb-btn:hover{background-color:hsla(0,0%,100%,.2)}
.h1dl-batch-bar .h1dl-bb-btn .material-icons{font-size:20px}
.h1dl-batch-bar .h1dl-bb-dl{color:#8ab4f8}
.h1dl-batch-bar .h1dl-bb-dl:disabled{opacity:.35;cursor:not-allowed}
.h1dl-spin{animation:1.2s linear infinite h1dl-rotate}

@keyframes h1dl-rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
`;
    document.head.appendChild(style);
    return style;
  }
  function createMenu(t, startDownload2) {
    let openMenu = null;
    function closeMenu() {
      if (openMenu) {
        document.querySelectorAll(".h1dl-pinned").forEach((el) => {
          el.classList.remove("h1dl-pinned");
        });
        openMenu.remove();
        openMenu = null;
      }
    }
    function closeSiteDropdowns() {
      document.querySelectorAll(".dropdown.open, .open").forEach((el) => {
        el.classList.remove("open");
        el.setAttribute("aria-expanded", "false");
      });
    }
    function placeMenu(menu, btn) {
      const visual = btn.querySelector(".video-show-action-btn") || btn;
      const vRect = visual.getBoundingClientRect();
      const cs = getComputedStyle(visual);
      const mt = Number.parseFloat(cs.marginTop) || 0;
      const sx = window.scrollX || window.pageXOffset;
      const sy = window.scrollY || window.pageYOffset;
      const menuW = menu.offsetWidth;
      let left = vRect.left + sx;
      const top = vRect.bottom - mt + sy + 4;
      if (vRect.left + menuW > window.innerWidth - 8) {
        left = window.innerWidth + sx - menuW - 8;
      }
      menu.style.left = Math.max(8, left) + "px";
      menu.style.top = top + "px";
    }
    function openQualityMenu(btn, videoId) {
      if (openMenu && openMenu.dataset.trigger === btn.dataset.h1dlTrigger) {
        closeMenu();
        return;
      }
      closeMenu();
      closeSiteDropdowns();
      const menu = document.createElement("div");
      menu.className = NATIVE_MENU_CLASS;
      menu.style.cssText = "display:block!important;z-index:2147483647;top:-9999px;left:-9999px";
      const triggerId = "t" + Math.random().toString(36).slice(2, 9);
      btn.dataset.h1dlTrigger = triggerId;
      menu.dataset.trigger = triggerId;
      const header = document.createElement("div");
      header.className = NATIVE_ITEM_CLASS;
      header.style.cssText = "cursor:default;color:#aaa;font-size:12px";
      header.textContent = t("loadingQualities");
      menu.appendChild(header);
      btn.classList.add("h1dl-pinned");
      document.body.appendChild(menu);
      openMenu = menu;
      placeMenu(menu, btn);
      queryQualities(videoId).then((list) => {
        if (openMenu !== menu) return;
        header.remove();
        if (!list.length) {
          header.textContent = t("noQualities");
          header.style.cursor = "default";
          menu.appendChild(header);
          return;
        }
        list.forEach((info) => {
          const item = document.createElement("div");
          item.className = NATIVE_ITEM_CLASS;
          const icon = document.createElement("i");
          icon.className = "material-icons";
          icon.textContent = "play_circle_filled";
          const span = document.createElement("span");
          span.textContent = info.quality;
          item.append(icon, span);
          item.addEventListener("click", (e) => {
            e.stopPropagation();
            closeMenu();
            startDownload2(btn, videoId, info.quality);
          });
          menu.appendChild(item);
        });
        placeMenu(menu, btn);
      });
      setTimeout(() => {
        document.addEventListener("click", closeMenu, { once: true });
      }, 0);
    }
    function askQuality(qualities) {
      return new Promise((resolve) => {
        closeMenu();
        const menu = document.createElement("div");
        menu.className = NATIVE_MENU_CLASS;
        menu.style.cssText = "position:fixed;z-index:2147483647;display:block;left:50%;top:30%;transform:translateX(-50%)";
        const header = document.createElement("div");
        header.className = NATIVE_ITEM_CLASS;
        header.style.cssText = "cursor:default;color:#aaa;font-size:12px";
        header.textContent = t("chooseBatchQuality");
        menu.appendChild(header);
        qualities.forEach((q) => {
          const item = document.createElement("div");
          item.className = NATIVE_ITEM_CLASS;
          const icon = document.createElement("i");
          icon.className = "material-icons";
          icon.textContent = "play_circle_filled";
          const span = document.createElement("span");
          span.textContent = q;
          item.append(icon, span);
          item.addEventListener("click", (e) => {
            e.stopPropagation();
            closeMenu();
            resolve(q);
          });
          menu.appendChild(item);
        });
        document.body.appendChild(menu);
        openMenu = menu;
        setTimeout(() => {
          document.addEventListener(
            "click",
            () => {
              closeMenu();
              resolve(null);
            },
            { once: true }
          );
        }, 0);
      });
    }
    return {
      close: closeMenu,
      isOpen: () => openMenu !== null,
      element: () => openMenu,
      open: openQualityMenu,
      askQuality
    };
  }
  const HOSTS = ["hanime1.com", "hanime1.me", "hanimeone.com", "hanimeone.me"];
  configureGmApi({
    GM_xmlhttpRequest: _GM_xmlhttpRequest,
    GM_download: _GM_download,
    GM_getValue: _GM_getValue,
    GM_setValue: _GM_setValue,
    GM_registerMenuCommand: _GM_registerMenuCommand
  });
  publishPresence({
    id: "h1dl",
    name: "Hanime1 Download Manager",
    description: "Replace download links with a native-style quality menu, batch download mode, and in-page downloads without leaving the page.",
    localeUrl: "https://raw.githubusercontent.com/OG-Open-Source/UserScripts/main/packages/hanime1-download-manager/dist/locales.json"
  });
  window.addEventListener("userscript:settings", (event) => {
    const lang = event.detail?.lang;
    if (lang) gmSet("lang", lang);
  });
  const ON_MANAGER = location.hostname === "og-open-source.github.io" && location.pathname.startsWith("/UserScripts");
  if (ON_MANAGER) ;
  else if (!HOSTS.includes(location.hostname)) {
    throw new Error("[h1dl] not a hanime1 host");
  } else {
    boot();
  }
  function boot() {
    void start();
  }
  async function start() {
    const { t } = await createI18n({
      source: { scriptId: "h1dl", url: "https://raw.githubusercontent.com/OG-Open-Source/UserScripts/main/packages/hanime1-download-manager/dist/locales.json" },
      fallback: MESSAGES,
      storageKey: "lang"
    });
    const THEME = probeTheme({
      panelClass: "more-horiz-panel",
      actionClass: "video-show-action-btn default",
      itemHover: "hsla(0,0%,100%,.2)"
    });
    injectStyles(THEME);
    let activeDownloads = 0;
    const bumpDownloads = (delta) => {
      activeDownloads = Math.max(0, activeDownloads + delta);
      const should = activeDownloads > 0;
      if (should !== bumpDownloadsFlag) {
        bumpDownloadsFlag = should;
        window[should ? "addEventListener" : "removeEventListener"]("beforeunload", onBeforeUnload);
      }
    };
    let bumpDownloadsFlag = false;
    function onBeforeUnload(e) {
      e.preventDefault();
      e.returnValue = "";
    }
    const setState = createStateMachine(t);
    const singleDownload = (btn, videoId, quality) => {
      void startDownload(btn, videoId, quality, setState, bumpDownloads);
    };
    const menu = createMenu(t, singleDownload);
    const batch = createBatch(t, menu.askQuality, bumpDownloads);
    const inject = createInject(t, menu, batch, setState);
    let injecting = false;
    let scanTimer = null;
    function scan() {
      injecting = true;
      try {
        inject.scan();
      } finally {
        injecting = false;
      }
    }
    function scheduleScan() {
      if (scanTimer) return;
      scanTimer = window.setTimeout(() => {
        scanTimer = null;
        scan();
      }, 300);
    }
    const observer = new MutationObserver((mutations) => {
      if (injecting) return;
      if (menu.isOpen()) {
        const openMenu = menu.element();
        const ownMutation = mutations.some(
          (m) => Array.from(m.addedNodes).includes(openMenu) || m.target === openMenu || openMenu?.contains(m.target) || batch.element() === m.target || batch.element()?.contains(m.target)
        );
        if (!ownMutation) menu.close();
      }
      scheduleScan();
    });
    observer.observe(document.documentElement, {
      childList: true,
      subtree: true
    });
    document.addEventListener("keydown", (e) => {
      if (e.key.toLowerCase() === "d" && (e.ctrlKey || e.metaKey) && e.shiftKey) {
        e.preventDefault();
        e.stopPropagation();
        batch.setMode(!batch.isOn());
      }
    });
    scan();
  }

})();