Greasy Fork

Greasy Fork is available in English.

Nexus No Wait ++

Skip Countdown, Auto Download, and More for Nexus Mods. Supports (Manual/Vortex/MO2/NMM)

当前为 2026-01-26 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Nexus No Wait ++
// @description Skip Countdown, Auto Download, and More for Nexus Mods. Supports (Manual/Vortex/MO2/NMM)
// @version     2.0.1
// @namespace   NexusNoWaitPlusPlus
// @author      Torkelicious
// @iconURL     https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png
// @icon        https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png
// @license     GPL-3.0-or-later
// @include     https://*.nexusmods.com/*
// @run-at      document-idle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM.xmlHttpRequest
// @grant       GM_info
// @grant       GM_addStyle
// @grant       GM_listValues
// @grant       GM_deleteValue
// @connect     *.nexusmods.com
// @connect     raw.githubusercontent.com
// ==/UserScript==

;(function () {
  'use strict'

  // Config
  const CONFIG_KEY = 'NexusNoWaitPP'
  const DEFAULTS = {
    AutoStartDownload: true,
    AutoCloseTab: true,
    SkipRequirements: true,
    ShowAlertsOnError: true,
    PlayErrorSound: true,
    ErrorSoundUrl: 'https://github.com/torkelicious/nexus-no-wait-pp/raw/refs/heads/main/errorsound.mp3',
    HandleArchivedFiles: true,
    HidePremiumUpsells: false,
    CloseTabDelay: 2000,
    RequestTimeout: 30000
  }
  function loadConfig() {
    try {
      const raw = typeof GM_getValue === 'function' ? GM_getValue(CONFIG_KEY, null) : null
      return raw
        ? {
            ...DEFAULTS,
            ...(typeof raw === 'string' ? JSON.parse(raw) : raw)
          }
        : DEFAULTS
    } catch (e) {
      return DEFAULTS
    }
  }

  // this exists because previous versions have a different config system
  async function cleanResetConfig() {
    // remove all GM storage keys stored
    if (typeof GM_listValues === 'function' && typeof GM_deleteValue === 'function') {
      const keys = await GM_listValues()
      for (const key of keys) {
        await GM_deleteValue(key)
      }
    }
    // Reset cfg to default
    Object.assign(cfg, DEFAULTS)
    // save defaults back to storage
    if (typeof GM_setValue === 'function') {
      await GM_setValue(CONFIG_KEY, JSON.stringify(cfg))
    }
    location.reload()
  }

  let cfg = loadConfig()

  const Logger = (() => {
    const prefix = () => `[NexusNoWait++ v${GM_info.script.version}]`
    const format = (...args) => [prefix(), ...args, `\n at:(${location.href})`]
    const log =
      level =>
      (...args) =>
        console[level](...format(...args))
    return {
      debug: log('debug'),
      info: log('info'),
      warn: log('warn'),
      error: log('error')
    }
  })()

  let errorAudioPlayer = null
  function setupAudio() {
    // audio preloading
    if (!cfg.PlayErrorSound || !cfg.ErrorSoundUrl) return
    errorAudioPlayer = new Audio(cfg.ErrorSoundUrl)
    errorAudioPlayer.preload = 'auto'
    errorAudioPlayer.load()
  }

  function playErrorSound() {
    if (errorAudioPlayer) {
      errorAudioPlayer.currentTime = 0
      errorAudioPlayer.play().catch(e => Logger.warn('Error playing sound:', e))
    }
  }

  // NXM URL helpers
  function getURLPathSegment(index) {
    return window.location.pathname.split('/')[index] || null
  }
  function parseNXMParamsFromURL(text, params = {}) {
    const inputText = String(text || '')
    const mappings = [
      { regex: /(?:md5|key)=([^&"']+)/, key: 'key' },
      { regex: /(?:expires|exp)=([^&"']+)/, key: 'expires' },
      { regex: /user_id=([^&"']+)/, key: 'user_id' },
      {
        regex: /(?:file_id)=([^&"']+)/,
        key: 'fileId',
        condition: () => !params.fileId
      }
    ]
    for (const { regex, key, condition = () => true } of mappings) {
      const match = inputText.match(regex)?.[1]
      if (match && condition()) params[key] = match
    }
    params.game ||= getURLPathSegment(1)
    params.modId ||= getURLPathSegment(3)
    return params
  }
  function buildNXMUrl(params = {}) {
    const needed = ['game', 'modId', 'fileId', 'key', 'expires', 'user_id']
    if (needed.some(k => !params[k])) return null
    return `nxm://${params.game}/mods/${params.modId}/files/${params.fileId}?key=${params.key}&expires=${params.expires}&user_id=${params.user_id}`
  }

  function parseDownloadURLFromResponse(text) {
    if (!text) return null
    const inputText = String(text)
    try {
      const json = JSON.parse(inputText)
      if (json && json.url) {
        return {
          url: json.url.replace(/&/g, '&'),
          source: 'json-url'
        }
      }
    } catch (_) {}
    const match = inputText.match(/id=["']dl_link["'][^>]*value=["']([^"']+)["']/i)
    if (match) {
      return {
        url: match[1].replace(/&/g, '&'),
        source: 'dl_link-value'
      }
    }
    return null
  }

  function getGameId() {
    const sectionElement = document.getElementById('section')
    return sectionElement?.dataset?.gameId || ''
  }

  // unified download URL function
  async function getDownloadUrl({ fileId, gameId, isNMM }) {
    if (!fileId) return { url: null, error: 'Missing fileId' }

    if (isNMM) {
      // NMM logic
      const popupEndpoint = `/Core/Libs/Common/Widgets/DownloadPopUp?id=${encodeURIComponent(fileId)}&game_id=${encodeURIComponent(gameId || '')}`
      let responseText = ''
      await new Promise(resolve => {
        GM.xmlHttpRequest({
          method: 'GET',
          url: popupEndpoint,
          headers: { 'X-Requested-With': 'XMLHttpRequest' },
          onload(response) {
            responseText = response.response || response.responseText || ''
            resolve()
          },
          onerror: resolve,
          ontimeout: resolve
        })
      })
      if (!responseText) return { url: null, error: 'Empty response' }
      const extracted = parseDownloadURLFromResponse(responseText)
      if (!extracted)
        return {
          url: null,
          error: 'No URL extracted\n(Are you logged in?)'
        }
      if (/^nxm:\/\//i.test(extracted.url)) return { url: extracted.url }
      if (/^https?:\/\//i.test(extracted.url)) {
        const params = parseNXMParamsFromURL(extracted.url, {
          fileId,
          modId: getURLPathSegment(3),
          game: getURLPathSegment(1)
        })
        const nxm = buildNXMUrl(params)
        return { url: nxm || extracted.url }
      }
      return { url: null, error: 'Unknown URL type' }
    } else {
      // Manual logic
      const endpoint = '/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl'
      const body = `fid=${encodeURIComponent(fileId)}&game_id=${encodeURIComponent(gameId || '')}`
      return await new Promise(resolve => {
        GM.xmlHttpRequest({
          method: 'POST',
          url: endpoint,
          data: body,
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
            'X-Requested-With': 'XMLHttpRequest',
            Origin: 'https://www.nexusmods.com',
            Referer: location.href
          },
          timeout: cfg.RequestTimeout,
          onload(response) {
            const responseText = response.response || response.responseText || ''
            const extracted = parseDownloadURLFromResponse(responseText)
            if (extracted) resolve({ url: extracted.url })
            else
              resolve({
                url: null,
                error: 'No URL in response\n(Are you logged in?)'
              })
          },
          onerror() {
            resolve({ url: null, error: 'Request failed' })
          },
          ontimeout() {
            resolve({ url: null, error: 'Timeout' })
          }
        })
      })
    }
  }

  function setButtonState(button, state, message) {
    try {
      const textElement = button.querySelector('span.flex-label, span') || button
      const stateConfig = {
        waiting: { text: 'Please Wait...', color: 'orange' },
        downloading: { text: 'Downloading!', color: 'green' },
        error: { text: message || 'Error', color: 'red' }
      }
      const config = stateConfig[state] || stateConfig.error
      textElement.innerText = config.text
      button.style.color = config.color
    } catch (e) {}
  }

  function attachClickInterceptor() {
    async function handleDownload(btn, fileId, isNMM) {
      setButtonState(btn, 'waiting')
      Logger.debug('fileId', fileId, 'isNMM', isNMM)
      const { url, error } = await getDownloadUrl({
        fileId,
        gameId: getGameId(),
        isNMM
      })
      if (error) {
        setButtonState(btn, 'error', error)
        if (cfg.PlayErrorSound) playErrorSound()
        if (cfg.ShowAlertsOnError) alert(`Download error: ${error}`)
        return
      }
      setButtonState(btn, 'downloading')
      location.assign(url)
    }

    const extractFileId = href => {
      try {
        const url = new URL(href, location.href)
        return url.searchParams.get('file_id') || url.searchParams.get('id')
      } catch {}
      return null
    }

    document.body.addEventListener(
      'click',
      async function (event) {
        const element = event.target.closest('a,button')
        if (!element) return

        const linkHref = element.href || element.getAttribute('href') || ''
        if (!linkHref) return
        const fileId = extractFileId(linkHref)
        if (!fileId) return

        const hasRequirements = linkHref.includes('ModRequirementsPopUp') || linkHref.includes('tab=requirements')
        const isNMM = linkHref.includes('nmm=1') || linkHref.includes('&nmm') || element.closest('#action-nmm') !== null

        // If SkipRequirements is enabled and this is a requirements popup button, trigger download directly
        if (hasRequirements && cfg.SkipRequirements) {
          event.preventDefault()
          event.stopImmediatePropagation()
          handleDownload(element, fileId, isNMM)
          return
        }

        // If requirements are present and skip is not enabled, let the popup/tab open as normal
        if (hasRequirements && !cfg.SkipRequirements) {
          return
        }

        // Otherwise handle as normal download
        event.preventDefault()
        event.stopImmediatePropagation()
        handleDownload(element, fileId, isNMM)
      },
      true
    )

    // Intercept "Slow download" button on file_id pages
    if (location.search.includes('file_id')) {
      const setupSlowDownloadIntercept = () => {
        const modFileDownload = document.querySelector('mod-file-download')
        if (modFileDownload?.shadowRoot) {
          const slowDownloadBtn = modFileDownload.shadowRoot.querySelector(
            '#upsell-cards > div.flex.flex-col.justify-between.gap-y-6.rounded-lg.bg-surface-translucent-low.p-6 > button'
          )
          if (slowDownloadBtn) {
            slowDownloadBtn.addEventListener('click', async event => {
              event.preventDefault()
              event.stopImmediatePropagation()
              const params = new URLSearchParams(location.search)
              const fileId = params.get('file_id')
              if (!fileId) return
              const isNMM = params.has('nmm') || params.get('nmm') === '1'
              Logger.debug('Slow download intercept: fileId', fileId, 'isNMM', isNMM)
              setButtonState(slowDownloadBtn, 'waiting')
              const { url } = await getDownloadUrl({
                fileId,
                gameId: getGameId(),
                isNMM
              })
              if (url) {
                setButtonState(slowDownloadBtn, 'downloading')
                Logger.info(`Slow download ${isNMM ? 'NMM' : 'manual'}: starting download`)
                location.assign(url)
              }
            })
          }
        }
      }

      setupSlowDownloadIntercept()
      const observer = new MutationObserver(() => {
        setupSlowDownloadIntercept()
      })
      observer.observe(document.body, { childList: true, subtree: true })
    }
  }

  function interceptRequirementsTab() {
    document.body.addEventListener(
      'click',
      function (event) {
        const linkElement = event.target.closest("a[href*='tab=requirements']")
        if (!linkElement) return
        if (!cfg.SkipRequirements) return
        event.preventDefault()
        event.stopImmediatePropagation()
        const linkHref = linkElement.href || linkElement.getAttribute('href') || ''
        location.replace(linkHref.replace('tab=requirements', 'tab=files'))
      },
      true
    )
  }

  async function autoStartDownload() {
    if (!cfg.AutoStartDownload) return
    const params = new URLSearchParams(location.search)
    const fileId = params.get('file_id')
    if (!fileId) return
    const isNMM = params.has('nmm') || params.get('nmm') === '1'
    Logger.debug('Auto-start: fileId', fileId, 'isNMM', isNMM)
    await new Promise(r => setTimeout(r, 200))
    const { url } = await getDownloadUrl({
      fileId,
      gameId: getGameId(),
      isNMM
    })
    if (url) {
      Logger.info(
        `Auto ${isNMM ? 'NMM' : 'manual'}: final URL type`,
        url.startsWith('nxm://') ? 'nxm' : url.startsWith('https://') ? 'https' : 'other'
      )
      location.assign(url)
      if (cfg.AutoCloseTab) setTimeout(() => window.close(), cfg.CloseTabDelay)
    }
  }

  function upsellBlocker() {
    if (!cfg.HidePremiumUpsells) return
    const elementsToHideSelectors = [
      // IDs
      '#nonPremiumBanner',
      '#freeTrialBanner',
      '#ig-banner-container',
      '#rj-vortex',
      // broad class matches for dynamic content
      '[class*="ads-bottom"]',
      '[class*="ads-top"]',
      '[class*="to-premium"]',
      '[class*="from-premium"]',
      '[class*="premium"]',
      // specific page elements
      '#mainContent > div.ads-holder.clearfix.ads-top',
      '#mainContent > div.ads-holder.clearfix.ads-bottom',
      '#mainContent > div > div.relative.next-container > div > section.flex.items-center.justify-center > div',
      '#mainContent > div > div.relative.next-container > div > a',
      '#headlessui-menu-items-_r_ap_ > div.flex.flex-col.gap-y-4.px-3.py-2 > div.hidden.md\\:block',
      '#head > div.rj-right-tray.rj-profile-tray.rj-open > ul > li.user-profile-menu-section-top > a',
      '#mainContent > div.flex.items-center.justify-center.gap-x-4.border-y.border-stroke-subdued.bg-surface-low.py-2',
      '#mainContent > div.hidden.items-center.justify-center.gap-x-4.border-b.border-stroke-subdued.bg-surface-low.py-2.md\\:flex',
      '#mainContent > div.relative > div.relative.next-container.pb-20 > div.space-y-16 > div.relative.overflow-hidden.rounded-lg.border-2.border-\[\#FCD23F\]',
      '#mainContent > div.relative > div.relative.next-container.pb-20 > div.mb-6.w-full.space-y-6.border-b.border-stroke-weak.pt-4.pb-6.sm\\:mb-0.sm\\:border-none.sm\\:pb-8 > section > div.flex.flex-col.gap-2.rounded-sm.bg-surface-translucent-low.p-2.5.backdrop-blur-xs.xs\\:w-fit.xs\\:max-w-sm.order-4.h-fit.w-full',
      '#filters-panel > div.mt-4.hidden.rounded-lg.border.border-creator-subdued.bg-creator-weak.bg-cover.p-4'
    ]
    // hide all selectors
    GM_addStyle(elementsToHideSelectors.map(selector => `${selector}{display:none!important}`).join('\n'))

    // hide upsells in shadow root
    const modFileDownloadElement = document.querySelector('mod-file-download')
    if (modFileDownloadElement?.shadowRoot) {
      const shadowStyle = document.createElement('style')
      shadowStyle.textContent =
        '#upsell-cards > div.relative.flex.flex-col.justify-between.gap-y-6.rounded-lg.border.bg-gradient-to-t.from-premium-weak.from-25\\%.to-premium-900.to-75\\%.p-6.sm\\:order-last.border-premium-100.border-premium-moderate{display:none!important}'
      modFileDownloadElement.shadowRoot.appendChild(shadowStyle)
    }
    // hide premium upsell banner
    const premiumBanner = document.querySelector('.bg-nexus-premium-gradient')
    if (premiumBanner) {
      premiumBanner.remove()
      Logger.info('Removed premium upsell banner')
    }
  }

  function waitForElement(selector, cb) {
    const el = document.querySelector(selector)
    if (el) cb(el)
    const mo = new MutationObserver(() => {
      const el = document.querySelector(selector)
      if (el) {
        cb(el)
      }
    })
    mo.observe(document.body, { childList: true, subtree: true })
  }

  function archivedFileHandler() {
    if (!cfg.HandleArchivedFiles) return
    const url = location.href
    if (url.includes('tab=files') && !url.includes('category=archived')) {
      waitForElement('#files-tab-footer', footer => {
        footer.querySelector('p')?.style.setProperty('display', 'none')
        // Check for any existing 'File archive' button
        const hasArchiveBtn = Array.from(footer.querySelectorAll('a.btn.inline-flex .flex-label')).some(
          el => el.textContent.trim() === 'File archive'
        )
        if (!hasArchiveBtn) {
          footer.insertAdjacentHTML(
            'beforeend',
            `<a class="btn inline-flex" data-archived-btn="true" href="${url}&category=archived" style="background:#da8e35;color:#fff;margin-left:8px;"><span class="flex-label">File archive</span></a>`
          )
        }
      })
    }
    if (!url.includes('category=archived')) return
    const headers = Array.from(document.getElementsByClassName('file-expander-header'))
    const downloads = Array.from(document.getElementsByClassName('accordion-downloads'))
    const base = location.origin + location.pathname
    for (const [i, header] of headers.entries()) {
      const fileId = header?.dataset?.id
      const box = downloads[i]
      if (!fileId || !box || box.dataset.done) continue
      box.dataset.done = '1'
      box.innerHTML = `
      <a class="btn inline-flex" href="${base}?tab=files&file_id=${fileId}&nmm=1"><span class="flex-label">Mod manager download</span></a>
      <a class="btn inline-flex" href="${base}?tab=files&file_id=${fileId}"><span class="flex-label">Manual download</span></a>
    `
    }
  }

  function main() {
    setupAudio()
    attachClickInterceptor()
    interceptRequirementsTab()
    autoStartDownload()
    upsellBlocker()
    archivedFileHandler()
    SettingsUI()
    Logger.debug('NNW++ initiated')
  }

  function SettingsUI() {
    const SETTING_UI = [
      {
        key: 'AutoStartDownload',
        label: 'Auto Start Download on file_id= URLs',
        type: 'bool',
        description: 'Automatically start downloads when visiting file download pages (URLs containing file_id=)'
      },
      {
        key: 'AutoCloseTab',
        label: 'Auto-Close Tab After Automatic Download ',
        type: 'bool',
        description: 'Automatically close the tab after a download starts on file download pages',
        showIf: () => cfg.AutoStartDownload
      },
      {
        key: 'SkipRequirements',
        label: 'Skip Requirements PopUp/Tab',
        type: 'bool',
        description: 'Skip the requirements popup/page and proceed directly to download'
      },
      {
        key: 'ShowAlertsOnError',
        label: 'Show Alert Messages on Errors',
        type: 'bool',
        description: 'Display error messages as browser popup alerts'
      },
      {
        key: 'PlayErrorSound',
        label: 'Play Error Sound',
        type: 'bool',
        description: 'Play an error sound when download errors occur'
      },

      {
        key: 'HidePremiumUpsells',
        label: 'Hide Premium Upsells & misc Annoyances (experimental)',
        type: 'bool',
        description:
          'Hide premium upgrade banners, trial offers, and other Annoyances on the site (experimental)\n slow and buggy, you are probably better off using an adblocker.'
      },
      {
        key: 'RequestTimeout',
        label: 'Request Timeout',
        type: 'number',
        description: 'Maximum time to wait for server responses before timing out (in milliseconds)'
      },
      {
        key: 'CloseTabDelay',
        label: 'Auto-Close Tab Delay',
        type: 'number',
        description: 'Delay before automatically closing the tab after download starts (in milliseconds)',
        showIf: () => cfg.AutoCloseTab
      },
      {
        key: 'ErrorSoundUrl',
        label: 'Error Sound URL',
        type: 'text',
        description: 'URL of the custom sound file to play for error alerts',
        showIf: () => cfg.PlayErrorSound
      },
      {
        key: 'HandleArchivedFiles',
        label: 'Generate download buttons for Archived Files',
        type: 'bool',
        description: 'Enable handling of archived files.'
      }
    ]
    const STYLES = {
      btn: "position:fixed;bottom:20px;right:20px;background:#2f2f2f;color:#fff;padding:10px 15px;border-radius:4px;cursor:pointer;z-index:9999;font-family:'Inter','Helvetica Neue', Helvetica, Arial, sans-serif;font-size:14px;border:none;",
      modal:
        "position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2f2f2f;color:#dadada;padding:25px;border-radius:4px;z-index:10000;min-width:300px;max-width:90%;max-height:90vh;overflow-y:auto;font-family:'Inter','Helvetica Neue', Helvetica, Arial, sans-serif;",
      backdrop: 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.5);z-index:9999;',
      section: 'background:#363636;padding:15px;border-radius:4px;margin-bottom:15px;',
      sectionHeader: 'color:#da8e35;margin:0 0 10px 0;font-size:16px;font-weight:500;',
      input: 'background:#2f2f2f;border:1px solid #444;color:#dadada;border-radius:3px;padding:5px;',
      row: 'margin-bottom:10px;',
      label: 'display:flex;align-items:center;gap:8px;',
      btnObj: {
        primary: 'padding:8px 15px;border:none;background:#da8e35;color:white;border-radius:3px;cursor:pointer;',
        secondary:
          'padding:8px 15px;border:1px solid #da8e35;background:transparent;color:#da8e35;border-radius:3px;cursor:pointer;',
        advanced: 'padding:4px 8px;background:transparent;color:#666;border:none;cursor:pointer;',
        closeX:
          'position:absolute;top:10px;right:10px;background:transparent;border:none;color:#fff;font-size:18px;cursor:pointer;line-height:1;padding:5px;'
      }
    }
    function save() {
      try {
        GM_setValue(CONFIG_KEY, JSON.stringify(cfg))
      } catch (e) {}
    }
    let activeModal = null
    let activeBackdrop = null
    function showSettingsModal() {
      cfg = loadConfig()
      if (activeModal) activeModal.remove()
      if (activeBackdrop) activeBackdrop.remove()

      const backdrop = document.createElement('div')
      backdrop.style.cssText = STYLES.backdrop
      backdrop.addEventListener('click', closeModal)
      document.body.appendChild(backdrop)
      activeBackdrop = backdrop

      const modal = document.createElement('div')
      modal.style.cssText = STYLES.modal

      const build = setting => {
        const shouldShow = !setting.showIf || setting.showIf()
        if (setting.type === 'bool')
          return `<div style="${STYLES.row};display:${shouldShow ? 'block' : 'none'}"><label title="${setting.description}" style="${STYLES.label}"><input type="checkbox" data-setting="${setting.key}" ${cfg[setting.key] ? 'checked' : ''}><span>${setting.label}</span></label></div>`
        if (setting.type === 'number') {
          const step = setting.key === 'CloseTabDelay' ? 100 : 1
          return `<div style="${STYLES.row};display:${shouldShow ? 'block' : 'none'}"><label title="${setting.description}" style="${STYLES.label}"><span>${setting.label}:</span><input type="number" value="${cfg[setting.key]}" min="0" step="${step}" data-setting="${setting.key}" style="${STYLES.input};width:120px;"></label></div>`
        }
        if (setting.type === 'text')
          return `<div style="${STYLES.row};display:${shouldShow ? 'block' : 'none'}"><label title="${setting.description}" style="${STYLES.label}"><span style="font-size:0.9em;color:#aaa;">${setting.label}:</span><input type="text" value="${cfg[setting.key]}" data-setting="${setting.key}" style="${STYLES.input};width:95%;"></label></div>`
        return ''
      }

      const features = SETTING_UI.filter(u => (u.type === 'bool' || u.type === 'text') && u.key !== 'RefreshOnError')
        .map(build)
        .join('')
      const timing = SETTING_UI.filter(u => u.type === 'number')
        .map(build)
        .join('')

      modal.innerHTML = `
        <style>a:hover { text-decoration: underline !important; }</style>
        <button id="closeSettingsX" style="${STYLES.btnObj.closeX}">×</button>
        <h3 style="${STYLES.sectionHeader}">NexusNoWait++ Settings</h3>
        <div style="${STYLES.section}"><h4 style="${STYLES.sectionHeader}">Features</h4>${features}</div>
        <div style="${STYLES.section}"><h4 style="${STYLES.sectionHeader}">Timing</h4>${timing}</div>
        <div style="display:flex;justify-content:center;gap:10px;margin-top:20px;"><button id="resetSettings" style="${STYLES.btnObj.secondary}">Reset Settings</button><button id="closeSettings" style="${STYLES.btnObj.primary}">Save & Close</button></div>
        <div style="text-align:center;margin-top:12px;color:#666;font-size:12px;">v${GM_info.script.version} by Torkelicious</div>
        <div style="text-align:center;margin-top:6px;color:#666;font-size:10px;"><a href="https://github.com/torkelicious/nexus-no-wait-pp/" target="_blank" style="color:#666;">This software is open-source and licensed under the GPLv3</a></div>
      `

      const update = element => {
        const key = element.getAttribute('data-setting')
        if (!key) return
        let value =
          element.type === 'checkbox'
            ? element.checked
            : element.type === 'number'
              ? parseInt(element.value, 10)
              : element.value
        if (typeof value === 'number' && isNaN(value)) {
          element.value = cfg[key]
          return
        }
        if (cfg[key] !== value) {
          cfg[key] = value
          save()
        }
        if (key === 'AutoStartDownload') {
          const row = modal.querySelector('[data-setting="AutoCloseTab"]')?.closest('div')
          if (row) row.style.display = element.checked ? 'block' : 'none'
        }
        if (key === 'AutoCloseTab') {
          const row = modal.querySelector('[data-setting="CloseTabDelay"]')?.closest('div')
          if (row) row.style.display = element.checked ? 'block' : 'none'
        }
        if (key === 'PlayErrorSound') {
          const row = modal.querySelector('[data-setting="ErrorSoundUrl"]')?.closest('div')
          if (row) row.style.display = element.checked ? 'block' : 'none'
        }
      }

      modal.addEventListener('change', event => {
        if (event.target?.hasAttribute('data-setting')) update(event.target)
      })
      modal.addEventListener('input', event => {
        if (
          (event.target.type === 'number' || event.target.type === 'text') &&
          event.target?.hasAttribute('data-setting')
        )
          update(event.target)
      })

      const closeX = modal.querySelector('#closeSettingsX')
      const closeBtn = modal.querySelector('#closeSettings')
      const resetBtn = modal.querySelector('#resetSettings')

      function closeModal() {
        if (activeModal) {
          activeModal.remove()
          activeModal = null
        }
        if (activeBackdrop) {
          activeBackdrop.remove()
          activeBackdrop = null
        }
        document.removeEventListener('keydown', onSettingsKeyDown)
      }
      const onSettingsKeyDown = event => {
        if (event.key === 'Escape') closeModal()
      }

      closeX.addEventListener('click', closeModal)
      closeBtn.addEventListener('click', closeModal)
      resetBtn.addEventListener('click', async () => {
        await cleanResetConfig()
        closeModal()
      })

      document.body.appendChild(modal)
      activeModal = modal
      document.addEventListener('keydown', onSettingsKeyDown)
    }

    if (!document.getElementById('nnwpp-btn')) {
      const btn = document.createElement('div')
      btn.id = 'nnwpp-btn'
      btn.textContent = 'NexusNoWait++ ⚙️'
      btn.style.cssText = STYLES.btn
      btn.onclick = showSettingsModal
      btn.onmouseover = () => (btn.style.transform = 'translateY(-2px)')
      document.body.appendChild(btn)
      // keep button persistent if removed by react hydration -.-
      const observer = new MutationObserver(() => {
        if (!document.getElementById('nnwpp-btn')) {
          document.body.appendChild(btn)
        }
      })
      observer.observe(document.body, { childList: true, subtree: true })
    }
  }

  main() // first run
  // spa navigation support to re-run main() on URL change
  let lastUrl = location.href
  const originalPushState = history.pushState
  const originalReplaceState = history.replaceState
  history.pushState = function (...args) {
    originalPushState.apply(this, args)
    if (location.href !== lastUrl) {
      lastUrl = location.href
      main()
    }
  }
  history.replaceState = function (...args) {
    originalReplaceState.apply(this, args)
    if (location.href !== lastUrl) {
      lastUrl = location.href
      main()
    }
  }
  window.addEventListener('popstate', () => {
    if (location.href !== lastUrl) {
      lastUrl = location.href
      main()
    }
  })
  // fallback for other changes
  new MutationObserver(() => {
    if (location.href !== lastUrl) {
      lastUrl = location.href
      main()
    }
  }).observe(document.body, { subtree: true, childList: true })
})()