Raw Source
AminAhmadyDeveloper / Modrinth Direct Download Pro

// ==UserScript==
// @name         Modrinth Direct Download Pro
// @namespace    https://aminahmady.vercel.app/
// @version      11.3
// @description  Direct download links + cache + loading + menu + loader/version/runtime selector
// @match        https://modrinth.com/*
// @grant        GM_xmlhttpRequest
// @connect      api.modrinth.com
// @license      MIT
// @downloadURL https://update.greasyfork.org/scripts/567195/Modrinth%20Direct%20Download%20Pro.user.js
// @updateURL https://update.greasyfork.org/scripts/567195/Modrinth%20Direct%20Download%20Pro.meta.js
// ==/UserScript==

(function () {
  "use strict";

  /* ================= SETTINGS + CACHE ================= */

  const SETTINGS_KEY = "mr-settings-full";
  const CACHE_KEY = "mr-cache-full";
  const HISTORY_KEY = "mr-history-full";
  const CACHE_TTL = 1000 * 60 * 60 * 12;

  const defaultSettings = {
    enabled: true,
    version: "1.21.1",
    loader: "fabric",
    shaderRuntime: "iris",
    notifyOnDownload: true,
  };

  let settings = {
    ...defaultSettings,
    ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"),
  };

  function saveSettings() {
    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
  }

  function loadCache() {
    return JSON.parse(localStorage.getItem(CACHE_KEY) || "{}");
  }

  function saveCache(cache) {
    localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
  }

  function makeCacheKey(slug, loader, version, runtime) {
    return `${slug}::${loader}::${version}::${runtime}`;
  }

  function getCached(slug, type) {
    const cache = loadCache();
    const key = makeCacheKey(slug, type);

    const entry = cache[key];
    if (!entry) return null;

    if (Date.now() - entry.timestamp > CACHE_TTL) {
      delete cache[key];
      saveCache(cache);
      return null;
    }

    return entry.url;
  }

  function setCached(slug, type, url) {
    const cache = loadCache();
    cache[makeCacheKey(slug, type)] = {
      url,
      timestamp: Date.now(),
    };
    saveCache(cache);
  }

  function loadHistory() {
    return JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
  }

  function saveHistory(history) {
    localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
  }

  function addToHistory(slug, type, title) {
    const history = loadHistory();
    history.unshift({
      slug,
      type,
      title,
      timestamp: Date.now(),
    });
    if (history.length > 50) history.pop();
    saveHistory(history);

    if (settings.notifyOnDownload) {
      const emoji = type === "mod" ? "📦" : type === "shader" ? "✨" : "🎨";
      showNotification(`${emoji} Downloaded: ${title}`);
    }
  }

  function showNotification(message) {
    const notification = document.createElement("div");
    notification.style.cssText = `
      position: fixed;
      top: 20px;
      right: 20px;
      background: #1bd96a;
      color: white;
      padding: 12px 16px;
      border-radius: 8px;
      font-family: system-ui;
      font-size: 14px;
      z-index: 1000000;
      box-shadow: 0 4px 12px rgba(0,0,0,.3);
      animation: slideIn 0.3s ease-out;
    `;
    notification.textContent = message;
    document.body.appendChild(notification);

    setTimeout(() => {
      notification.style.animation = "slideOut 0.3s ease-out";
      setTimeout(() => notification.remove(), 300);
    }, 3000);
  }

  const style = document.createElement("style");
  style.textContent = `
    .mr-fab {
      position: fixed;
      bottom: 20px;
      right: 20px;
      width: 56px;
      height: 56px;
      border-radius: 50%;
      background: #1bd96a;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      z-index: 999999;
      box-shadow: 0 5px 20px rgba(0,0,0,.5);
    }

    .mr-fab > svg {
      width: 28px;
      height: 28px;
    }

    .mr-panel {
      position: fixed;
      bottom: 90px;
      right: 20px;
      width: 300px;
      background: #1e1e1e;
      border-radius: 16px;
      padding: 16px;
      box-shadow: 0 10px 40px rgba(0,0,0,.6);
      color: white;
      display: none;
      z-index: 999999;
      font-family: system-ui;
    }

    .mr-panel label {
      font-size: 12px;
      opacity: .7;
    }

    .mr-panel select {
      width: 100%;
      margin-bottom: 12px;
      padding: 6px;
      border-radius: 6px;
      border: none;
    }

    .mr-save {
      width: 100%;
      padding: 8px;
      border: none;
      border-radius: 8px;
      background: #1bd96a;
      font-weight: 600;
      cursor: pointer;
    }

    .mr-switch {
      position: relative;
      width: 46px;
      height: 24px;
      background: #555;
      border-radius: 20px;
      cursor: pointer;
      margin-bottom: 14px;
      transition: .2s;
    }

    .mr-switch::after {
      content: '';
      position: absolute;
      width: 20px;
      height: 20px;
      background: white;
      border-radius: 50%;
      top: 2px;
      left: 2px;
      transition: .2s;
    }

    .mr-switch.active {
      background: #1bd96a;
    }

    .mr-switch.active::after {
      left: 24px;
    }

    .mr-loading {
      height: 100%;
      width: 100%;
      opacity: 0.2;
      position: relative;
      border-radius: 16px;
      background: linear-gradient(90deg,#444,#666,#444);
      background-size: 200% 100%;
      z-index: 101;
      animation: mr-shimmer 1.2s infinite;
    }

    @keyframes mr-shimmer {
      0% { background-position: 200% 0 }
      100% { background-position: -200% 0 }
    }

    .mr-cached-label {
      color: #ff4d4d;
      font-size: 12px;
      margin-left: 6px;
      font-weight: 600;
    }

    .mr-disabled {
      opacity: 0.4 !important;
      pointer-events: none !important;
    }

    .mr-action-badge {
      display: inline-block;
      font-size: 12px;
      margin-left: 6px;
      font-weight: 600;
      cursor: pointer;
      pointer-events: auto;
      transition: opacity 0.2s;
    }

    .mr-action-badge:hover {
      opacity: 0.7;
    }

    .mr-badge-open {
      color: #4a9eff;
    }

    .mr-badge-copy {
      color: #ff9f43;
    }

    .mr-clear {
      width: 100%;
      padding: 8px;
      border: none;
      border-radius: 8px;
      background: #ff4d4d;
      font-weight: 600;
      cursor: pointer;
      color: white;
      margin-top: 8px;
    }

    .mr-clear:hover {
      opacity: 0.9;
    }

    .mr-history-container {
      margin-top: 12px;
      padding-top: 12px;
      border-top: 1px solid #444;
      max-height: 200px;
      overflow-y: auto;
    }

    .mr-history-item {
      padding: 8px 0;
      font-size: 11px;
      border-bottom: 1px solid #333;
      line-height: 1.4;
    }

    .mr-history-item:last-child {
      border-bottom: none;
    }

    .mr-history-emoji {
      margin-right: 6px;
      font-size: 12px;
    }

    .mr-history-title {
      color: #1bd96a;
      word-break: break-word;
    }

    .mr-history-time {
      color: #999;
      font-size: 10px;
      margin-top: 2px;
    }

    @keyframes slideIn {
      from {
        transform: translateX(400px);
        opacity: 0;
      }
      to {
        transform: translateX(0);
        opacity: 1;
      }
    }

    @keyframes slideOut {
      from {
        transform: translateX(0);
        opacity: 1;
      }
      to {
        transform: translateX(400px);
        opacity: 0;
      }
    }
  `;
  document.head.appendChild(style);

  /* ================= MENU ================= */

  const fab = document.createElement("div");
  fab.className = "mr-fab";
  fab.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 24 24"><path fill="#fff" d="M12.252.004a11.78 11.768 0 0 0-8.92 3.73a11 11 0 0 0-2.17 3.11a11.37 11.359 0 0 0-1.16 5.169c0 1.42.17 2.5.6 3.77c.24.759.77 1.899 1.17 2.529a12.3 12.298 0 0 0 8.85 5.639c.44.05 2.54.07 2.76.02c.2-.04.22.1-.26-1.7l-.36-1.37l-1.01-.06a8.5 8.489 0 0 1-5.18-1.8a5.3 5.3 0 0 1-1.3-1.26c0-.05.34-.28.74-.5a37.572 37.545 0 0 1 2.88-1.629c.03 0 .5.45 1.06.98l1 .97l2.07-.43l2.06-.43l1.47-1.47c.8-.8 1.48-1.5 1.48-1.52c0-.09-.42-1.63-.46-1.7c-.04-.06-.2-.03-1.02.18c-.53.13-1.2.3-1.45.4l-.48.15l-.53.53l-.53.53l-.93.1l-.93.07l-.52-.5a2.7 2.7 0 0 1-.96-1.7l-.13-.6l.43-.57c.68-.9.68-.9 1.46-1.1c.4-.1.65-.2.83-.33c.13-.099.65-.579 1.14-1.069l.9-.9l-.7-.7l-.7-.7l-1.95.54c-1.07.3-1.96.53-1.97.53c-.03 0-2.23 2.48-2.63 2.97l-.29.35l.28 1.03c.16.56.3 1.16.31 1.34l.03.3l-.34.23c-.37.23-2.22 1.3-2.84 1.63c-.36.2-.37.2-.44.1c-.08-.1-.23-.6-.32-1.03c-.18-.86-.17-2.75.02-3.73a8.84 8.84 0 0 1 7.9-6.93c.43-.03.77-.08.78-.1c.06-.17.5-2.999.47-3.039c-.01-.02-.1-.02-.2-.03Zm3.68.67c-.2 0-.3.1-.37.38c-.06.23-.46 2.42-.46 2.52c0 .04.1.11.22.16a8.51 8.499 0 0 1 2.99 2a8.38 8.379 0 0 1 2.16 3.449a6.9 6.9 0 0 1 .4 2.8c0 1.07 0 1.27-.1 1.73a9.4 9.4 0 0 1-1.76 3.769c-.32.4-.98 1.06-1.37 1.38c-.38.32-1.54 1.1-1.7 1.14c-.1.03-.1.06-.07.26c.03.18.64 2.56.7 2.78l.06.06a12.07 12.058 0 0 0 7.27-9.4c.13-.77.13-2.58 0-3.4a11.96 11.948 0 0 0-5.73-8.578c-.7-.42-2.05-1.06-2.25-1.06Z"/></svg>`;
  document.body.appendChild(fab);

  const panel = document.createElement("div");
  panel.className = "mr-panel";
  panel.innerHTML = `
    <label>Enable Script</label>
    <div id="mr-switch" class="mr-switch"></div>

    <label>Minecraft Version</label>
    <select id="mr-version"></select>

    <label>Loader (Mods Only)</label>
    <select id="mr-loader"></select>

    <label>Shader Runtime</label>
    <select id="mr-runtime">
      <option value="iris">Iris</option>
      <option value="optifine">OptiFine</option>
    </select>

    <label>Notifications</label>
    <div id="mr-notify-switch" class="mr-switch"></div>

    <button class="mr-save">Save</button>
    <button id="mr-clear-cache" class="mr-clear">Clear Cache</button>

    <div class="mr-history-container">
      <label style="display: block; margin-bottom: 8px;">Download History</label>
      <div id="mr-history" style="font-size: 11px;"></div>
    </div>
  `;
  document.body.appendChild(panel);

  const clearCacheBtn = panel.querySelector("#mr-clear-cache");
  clearCacheBtn.onclick = () => {
    localStorage.removeItem(CACHE_KEY);
    alert("Cache cleared!");
  };

  fab.onclick = () => {
    panel.style.display = panel.style.display === "block" ? "none" : "block";
    if (panel.style.display === "block") {
      renderHistory();
    }
  };

  const switchEl = panel.querySelector("#mr-switch");
  const versionSelect = panel.querySelector("#mr-version");
  const loaderSelect = panel.querySelector("#mr-loader");
  const runtimeSelect = panel.querySelector("#mr-runtime");
  const notifySwitch = panel.querySelector("#mr-notify-switch");
  const saveBtn = panel.querySelector(".mr-save");
  const historyEl = panel.querySelector("#mr-history");

  function updateSwitch() {
    settings.enabled ?
      switchEl.classList.add("active") :
      switchEl.classList.remove("active");
  }

  function updateNotifySwitch() {
    settings.notifyOnDownload ?
      notifySwitch.classList.add("active") :
      notifySwitch.classList.remove("active");
  }

  function renderHistory() {
    const history = loadHistory();
    historyEl.innerHTML = "";
    if (history.length === 0) {
      historyEl.innerHTML = '<div style="color: #999; font-size: 10px;">No downloads yet</div>';
      return;
    }
    history.slice(0, 10).forEach((item) => {
      const emoji = item.type === "mod" ? "📦" : item.type === "shader" ? "✨" : "🎨";
      const date = new Date(item.timestamp);
      const timeStr = date.toLocaleString();
      const itemEl = document.createElement("div");
      itemEl.className = "mr-history-item";
      itemEl.innerHTML = `<span class="mr-history-emoji">${emoji}</span><span class="mr-history-title">${item.title}</span><div class="mr-history-time">${timeStr}</div>`;
      historyEl.appendChild(itemEl);
    });
  }

  updateSwitch();

  switchEl.onclick = () => {
    settings.enabled = !settings.enabled;
    updateSwitch();
  };

  updateNotifySwitch();

  notifySwitch.onclick = () => {
    settings.notifyOnDownload = !settings.notifyOnDownload;
    updateNotifySwitch();
    saveSettings();
  };

  function fetchTags(endpoint, selectEl, valueField, settingKey) {
    GM_xmlhttpRequest({
      method: "GET",
      url: `https://api.modrinth.com/v2/tag/${endpoint}`,
      onload: (r) => {
        const data = JSON.parse(r.responseText);
        selectEl.innerHTML = "";

        data.forEach((item) => {
          const opt = document.createElement("option");
          opt.value = item[valueField];
          opt.textContent = item.name || item.version;
          selectEl.appendChild(opt);
        });

        if (
          [...selectEl.options].some((o) => o.value === settings[settingKey])
        ) {
          selectEl.value = settings[settingKey];
        }
      },
    });
  }

  fetchTags("game_version", versionSelect, "version", "version");
  fetchTags("loader", loaderSelect, "name", "loader");

  runtimeSelect.value = settings.shaderRuntime;

  saveBtn.onclick = () => {
    settings.version = versionSelect.value;
    settings.loader = loaderSelect.value;
    settings.shaderRuntime = runtimeSelect.value;
    saveSettings();
    location.reload();
  };

  /* ================= PROJECT DETECTION ================= */

  function getProjectInfo(card) {
    const link = card.querySelector(
      'a[href^="/mod/"], a[href^="/resourcepack/"], a[href^="/shader/"]',
    );
    if (!link) return null;

    const match = link
      .getAttribute("href")
      .match(/^\/(mod|resourcepack|shader)\/([^/]+)/);

    if (!match) return null;

    return {
      type: match[1],
      slug: match[2],
    };
  }

  function addCachedLabel(titleEl) {
    if (!titleEl?.querySelector(".mr-cached-label")) {
      const span = document.createElement("span");
      span.className = "mr-cached-label";
      span.textContent = "(cached)";
      titleEl.appendChild(span);
    }
  }

  function makeCacheKey(slug, type) {
    if (type === "mod") {
      return `${slug}::mod::${settings.loader}::${settings.version}`;
    }

    if (type === "shader") {
      return `${slug}::shader::${settings.version}::${settings.shaderRuntime}`;
    }

    return `${slug}::resourcepack::${settings.version}`;
  }

  function addLoading(el) {
    if (el.querySelector(".mr-loading")) return;

    const loading = document.createElement("div");
    loading.className = "mr-loading";
    el.appendChild(loading);
  }

  function removeLoading(el) {
    const loading = el.querySelector(".mr-loading");
    if (loading) loading.remove();
  }

  function applyDownloadLink(link, url, slug, type, titleText) {
    link.href = url;
    link.setAttribute("download", "");
    link.setAttribute("target", "_blank");
    link.setAttribute("rel", "noopener noreferrer");

    // Clone the element to strip ALL existing event listeners
    const clone = link.cloneNode(true);
    link.parentNode.replaceChild(clone, link);

    clone.addEventListener(
      "click",
      (e) => {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();

        // Create a temporary link and trigger download
        const a = document.createElement("a");
        a.href = url;
        a.download = "";
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);

        // Add to history and notify only when actually clicked
        addToHistory(slug, type, titleText);
      },
      true,
    );
  }

  /* ================= FETCH LOGIC ================= */

  function fetchDownload(slug, type, cb) {
    let url;

    if (type === "mod") {
      url = `https://api.modrinth.com/v2/project/${slug}/version?loaders=["${settings.loader}"]&game_versions=["${settings.version}"]`;
    }
    else {
      url = `https://api.modrinth.com/v2/project/${slug}/version?game_versions=["${settings.version}"]`;
    }

    GM_xmlhttpRequest({
      method: "GET",
      url,
      onload: (r) => {
        const versions = JSON.parse(r.responseText);
        if (!versions.length) {
          setCached(slug, null);
          cb(null);
          return;
        }

        let selected = versions[0];

        if (type === "shader") {
          selected =
            versions.find((v) => v.loaders?.includes(settings.shaderRuntime)) ||
            null;
        }

        if (!selected) {
          setCached(slug, null);
          cb(null);
          return;
        }

        const file = selected.files[0];
        setCached(slug, file.url);
        cb(file.url);
      },
    });
  }

  /* ================= CARD PROCESSING ================= */

  function processCard(card) {
    if (!settings.enabled) return;

    const project = getProjectInfo(card);
    if (!project) return;

    const {
      slug,
      type
    } = project;

    const link = card.querySelector(
      'a[href^="/mod/"], a[href^="/resourcepack/"], a[href^="/shader/"]',
    );

    const cacheKey = makeCacheKey(slug, type);

    if (card.dataset.mrProcessed === cacheKey) return;
    card.dataset.mrProcessed = cacheKey;

    removeLoading(link);

    // Add action badges to title
    const titleEl = card.querySelector('h3, h4, h2, [class*="title"]');
    const titleText = titleEl?.textContent?.trim() || slug;

    if (titleEl) {
      // Check if we've already added badges to this titleEl
      if (!titleEl.dataset.mrBadgesProcessed) {
        titleEl.dataset.mrBadgesProcessed = true;

        const openBadge = document.createElement("span");
        openBadge.className = "mr-action-badge mr-badge-open";
        openBadge.textContent = "(Open)";
        openBadge.style.pointerEvents = "auto";
        openBadge.addEventListener("click", (e) => {
          e.preventDefault();
          e.stopPropagation();
          e.stopImmediatePropagation();
          window.open(`https://modrinth.com/${type}/${slug}`, "_blank");
        }, true);

        const copyBadge = document.createElement("span");
        copyBadge.className = "mr-action-badge mr-badge-copy";
        copyBadge.textContent = "(Copy)";
        copyBadge.style.pointerEvents = "auto";
        copyBadge.addEventListener("click", (e) => {
          e.preventDefault();
          e.stopPropagation();
          e.stopImmediatePropagation();
          navigator.clipboard.writeText(titleText).then(() => {
            copyBadge.textContent = "(Copied!)";
            setTimeout(() => {
              copyBadge.textContent = "(Copy)";
            }, 2000);
          });
        }, true);

        titleEl.appendChild(openBadge);
        titleEl.appendChild(copyBadge);
      }
    }

    const cached = getCached(slug, type);

    if (cached !== null) {
      if (cached) {
        link.href = cached;
        if (titleEl && !titleEl.querySelector(".mr-cached-label")) {
          addCachedLabel(titleEl);
        }
        applyDownloadLink(link, cached, slug, type, titleText);
      }
      else {
        card.classList.add("mr-disabled");
      }
      return;
    }

    addLoading(link);

    fetchDownload(slug, type, (url) => {
      removeLoading(link);

      if (!url) {
        card.classList.add("mr-disabled");
        return;
      }

      setCached(slug, type, url);
      applyDownloadLink(link, url, slug, type, titleText);
    });
  }

  function scan() {
    document.querySelectorAll(".smart-clickable").forEach(processCard);
  }

  scan();

  // second pass shortly after load, handles dynamic re-render that removes badges
  setTimeout(scan, 1000);

  new MutationObserver(scan).observe(document.body, {
    childList: true,
    subtree: true,
  });
})();