Replace download links with a native-style quality menu, batch download mode, and in-page downloads without leaving the page.
// ==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();
}
})();