Sleazy Fork is available in English.

Iwara Enhancement

Please refer to the script's homepage for more information.

Від 18.03.2023. Дивіться остання версія.

// ==UserScript==
// @name         Iwara Enhancement
// @name:zh-CN   Iwara增强
// @description  Please refer to the script's homepage for more information.
// @description:zh-CN 请参考脚本的主页以获取更多信息
// @noframes
// @grant        unsafeWindow
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_download
// @grant        GM_info
// @grant        GM_addStyle
// @require      https://unpkg.com/jquery@3.6.0
// @require      https://unpkg.com/vue@3.2.20
// @require      https://unpkg.com/vue-i18n@9.2.0-beta.26
// @match        *://*.iwara.tv/*
// @namespace    https://github.com/guansss/userscripts
// @version      1.0
// @author       guansss
// @source       https://github.com/guansss/userscripts
// @runAt        document-start
// @supportURL   https://github.com/guansss/userscripts/issues
// ==/UserScript==
;(() => {
  "use strict"

  $

  const external_Vue_namespaceObject = Vue

  function onClickOutside(el, callback) {
    document.addEventListener("click", handler)

    function handler(e) {
      if (!el.contains(e.target)) {
        document.removeEventListener("click", handler)
        callback(e)
      }
    }
  }

  /**
   * MutationObserver that calls callback with just a single mutation.
   */
  class SimpleMutationObserver extends MutationObserver {
    // since calling `new NodeList()` is illegal, this is the only way to create an empty NodeList

    constructor(callback) {
      super((mutations) => {
        for (const mutation of mutations) if (this.callback(mutation)) break
      })
      this.callback = callback
    }

    /**
     * @param options.immediate - When observing "childList", immediately trigger a mutation with existing nodes.
     */
    observe(target, options) {
      super.observe(target, options)

      if (options && options.immediate && options.childList && target.childNodes.length)
        this.callback({
          target,
          type: "childList",
          addedNodes: target.childNodes,
          removedNodes: SimpleMutationObserver.emptyNodeList,
        })
    }
  }

  SimpleMutationObserver.emptyNodeList = document.querySelectorAll("#__absolutely_nonexisting")
  function hasClass(node, className) {
    return !!node.classList?.contains(className)
  }

  let log

  setLogger(console.log)

  function setLogger(logger) {
    log = logger.bind(console, `[${GM_info.script.name}]`)
  }

  const external_VueI18n_namespaceObject = VueI18n

  const en_namespaceObject = JSON.parse(
    '{"language":"English","name":"Iwara Enhancement","description":"Multiple UI enhancements for better experience.","ui":{"show_list_options":"Show options","hide_list_options":"Hide options"},"s":{"enabled":"Enabled","download":{"label":"Download","auto":{"label":"One-click Download","desc":"Automatically starts download when clicking the download button.","warn":"This feature requires Tampermonkey.","warn_tm":["This feature requires Tampermonkey\'s <b>Browser API</b> download mode, please follow these steps:","Navigate to Tampermonkey\'s settings panel and then the <b>Settings</b> tab.","In <b>General</b> section, set <b>Config Mode</b> to <b>Advanced</b> (or <b>Beginner</b>).","In <b>Downloads BETA</b> section, set <b>Download Mode</b> to <b>Browser API</b>.","In <b>Downloads BETA</b> section, click <b>Save</b>.","Grant permission if requested.","Refresh this page."]},"resolution":{"label":"Preferred Resolution for Download"},"filename":{"label":"Filename","desc":"Filename template to use when downloading a video.<br>Each keyword should be surrounded by <b>{\'{ }\'}</b>.","preview":"Preview","key":{"id":"video\'s ID","title":"video\'s title","res":"video\'s resolution","author":"author\'s name","date":"date time when the download starts","up_date":"date time when the video was uploaded","date_ts":"DATE in timestamp format","up_date_ts":"UP_DATE in timestamp format"}}},"ui":{"label":"UI","like_rate":{"label":"Like rate","desc":"Displays like rates in video and image list."},"highlight_threshold":{"label":"Highlight threshold","desc":"Highlights video and image items over certain like rate."},"highlight_bg":{"label":"Highlight opacity"}},"script":{"label":"Script","language":{"label":"Language"}}}}'
  )

  const zh_namespaceObject = JSON.parse(
    '{"language":"中文","name":"Iwara增强","description":"多种增强体验的界面优化","s":{"enabled":"启用","download":{"label":"下载","auto":{"label":"一键下载","desc":"点击下载按钮时自动开始下载","warn":"该功能仅在 Tampermonkey 中可用","warn_tm":["该功能需要启用 Tampermonkey 的<b>浏览器 API</b>下载模式,请按照以下步骤启用:","进入 Tampermonkey 的设置面板,选择<b>设置</b>标签页","在<b>通用</b>里,设置<b>配置模式</b>为<b>高级<b>(或者<b>初学者</b>)","在<b>下载 BETA</b>里,设置<b>下载模式</b>为<b>浏览器 API</b>","在<b>下载 BETA</b>里,点击<b>保存</b>","如果请求权限的话,选择同意","刷新当前页面"]},"resolution":{"label":"优先下载的分辨率"},"filename":{"label":"文件名","desc":"下载视频时使用的文件名模板<br>每个关键词必须使用 <b>{\'{ }\'}</b> 来包围","preview":"预览","key":{"id":"视频 ID","title":"视频标题","author":"作者名","res":"视频分辨率","date":"下载开始时的日期和时间","up_date":"视频发布时的日期和时间","date_ts":"DATE 的时间戳格式","up_date_ts":"UP_DATE 的时间戳格式"}}},"ui":{"label":"界面","like_rate":{"label":"喜爱率","desc":"在视频和图片列表里显示喜爱率"},"highlight_threshold":{"label":"高亮分界点","desc":"喜爱率高于此值的视频和图片将会被高亮显示"},"highlight_bg":{"label":"高亮透明度"}},"script":{"label":"脚本","language":{"label":"语言"}}}}'
  )

  /* harmony default export */ const i18n = { zh: zh_namespaceObject, en: en_namespaceObject }

  function createStorage(prefix, schema) {
    if (prefix) prefix += "."

    return {
      get(key) {
        return GM_getValue(prefix + key, schema[key])
      },
      set(key, val) {
        if ("function" === typeof val) val = val(this.get(key))

        GM_setValue(prefix + key, val)
      },
    }
  }

  const storage = createStorage("", {
    v: GM_info.script.version,
    locale: navigator.language,
    volume: 0.5,
    auto_down_enabled: true,
    preferred_res: "Source",
    filename: "{DATE} {TITLE} - {AUTHOR} ({ID})",
    dark: false,
    like_rates: true,
    like_rate_highlight: 4,
    like_rate_highlight_opacity: 0.2,
    player_size: 100,
    hide_list_options: false,
  })

  const i18n_i18n = (0, external_VueI18n_namespaceObject.createI18n)({
    locale: storage.get("locale"),
    fallbackLocale: "en",
    messages: i18n,

    // disable warnings - I know what I'm doing!!
    silentFallbackWarn: true,
    silentTranslationWarn: true,
    warnHtmlInMessage: "off",
  })

  function matchLocale(locale) {
    return i18n_i18n.global.availableLocales.includes(locale)
      ? locale
      : i18n_i18n.global.availableLocales.find((loc) => locale.startsWith(loc)) || "en"
  }

  // shorthand helper making TypeScript happy
  function localize(message) {
    // @ts-ignore TS2589: Type instantiation is excessively deep and possibly infinite.
    return i18n_i18n.global.t(message)
  }

  const locale = (0, external_Vue_namespaceObject.ref)(storage.get("locale"))

  ;(0, external_Vue_namespaceObject.watchEffect)(() => {
    i18n_i18n.global.locale = locale.value

    storage.set("locale", locale.value)
  })

  function useConfigSettings() {
    // locale that will actually be used, with fallback applied
    const activeLocale = (0, external_Vue_namespaceObject.computed)(() => matchLocale(locale.value))

    return { locale, activeLocale }
  }

  /* harmony default export */ function mitt(n) {
    return {
      all: (n = n || new Map()),
      on: function (t, e) {
        var i = n.get(t)
        i ? i.push(e) : n.set(t, [e])
      },
      off: function (t, e) {
        var i = n.get(t)
        i && (e ? i.splice(i.indexOf(e) >>> 0, 1) : n.set(t, []))
      },
      emit: function (t, e) {
        var i = n.get(t)
        i &&
          i.slice().map(function (n) {
            n(e)
          }),
          (i = n.get("*")) &&
            i.slice().map(function (n) {
              n(t, e)
            })
      },
    }
  }

  const ready = new Promise((resolve) => {
    if ("loading" === document.readyState)
      document.addEventListener("DOMContentLoaded", () => resolve)
    else resolve()
  })

  function once(emitter, event, listener) {
    const fn = (data) => {
      emitter.off(event, fn)
      listener(data)
    }

    emitter.on(event, fn)

    return fn
  }

  function setupPaging() {
    ready.then(() => {
      const appDiv = document.getElementById("app")

      if (!appDiv) {
        log("Missing app div.")
        return
      }

      log("Start observing pages.")

      const appObserver = new SimpleMutationObserver((mutation) => {
        detectPageChange(appDiv, mutation.addedNodes, "pageEnter")
        detectPageChange(appDiv, mutation.removedNodes, "pageLeave")
      })
      appObserver.observe(appDiv, { childList: true, immediate: true })
    })
  }

  const emitter = mitt()

  let currentClassName = ""

  emitter.on("pageEnter", (className) => (currentClassName = className))

  const ALL = "*"

  // page listener for iwara
  function page(id, enter) {
    const match = (() => {
      if (id === ALL) return () => id

      const ids = "string" === typeof id ? [id] : id
      const classes = ids.map((id) => `page-${id}`)

      return (className) => {
        const split = className.split(" ")
        const index = classes.findIndex((cls) => split.includes(cls))

        return ids[index]
      }
    })()

    function callIfMatch(listener) {
      return (className) => {
        const matchedID = match(className)

        if (void 0 !== matchedID)
          try {
            listener(matchedID)
          } catch (e) {
            log("Error executing page listener", e)
          }
      }
    }

    const onPageEnter = callIfMatch((matchedID) => {
      enter(matchedID, (onLeave) => {
        once(emitter, "pageLeave", callIfMatch(onLeave))
      })
    })

    emitter.on("pageEnter", onPageEnter)
  }

  function detectPageChange(appDiv, nodes, event) {
    if (nodes.length)
      // a valid class name will be like "page page-videoList", where "videoList" is the ID
      for (const node of nodes)
        if (hasClass(node, "page")) {
          // sometimes there are two (maybe more) "page" elements, and one of them contains only the "page" class,
          // we ignore it in this case
          const hasOtherPageElements =
            $(appDiv)
              .children(".page")
              .filter((_, e) => e !== node).length > 0

          if (!hasOtherPageElements) emitter.emit(event, node.className)
          break
        }
  }
  let reactEventHandlersKey = ""

  function getReactEventHandlers(element) {
    if (reactEventHandlersKey) return element[reactEventHandlersKey]

    for (const key of Object.keys(element))
      if (key.startsWith("__reactEventHandlers$")) {
        reactEventHandlersKey = key
        return element[key]
      }
  }

  function clamp(val, min, max) {
    return val < min ? min : val > max ? max : val
  }

  function formatDate(date) {
    let delimiter = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : "/"
    return [
      date.getFullYear(),
      date.getMonth() + 1,
      date.getDate(),
      date.getHours(),
      date.getMinutes(),
      date.getSeconds(),
    ]
      .map((num) => String(num).padStart(2, "0"))
      .join(delimiter)
  }

  function adjustHexColor(color, amount) {
    return color.replace(/\w\w/g, (color) =>
      clamp(parseInt(color, 16) + amount, 0, 255)
        .toString(16)
        .padStart(2, "0")
    )
  }

  // sometimes I just don't want the script to depend on Lodash...
  function throttle(fn, timeout) {
    let timer = 0

    return function () {
      for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++)
        args[_key] = arguments[_key]
      if (timer) return

      timer = setTimeout(() => {
        fn.apply(null, args)

        timer = 0
      }, timeout)
    }
  }

  /**
   * Periodically calls given function until it returns true.
   */
  function repeat(fn) {
    let interval = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 200
    if (fn()) return 0

    const id = setInterval(() => {
      try {
        fn() && clearInterval(id)
      } catch (e) {
        log(e)
        clearInterval(id)
      }
    }, interval)

    return id
  }

  /**
   * Periodically calls given function until the return value is truthy.
   * @returns A CancelablePromise that resolves with the function's return value when truthy.
   */
  function until(fn) {
    let interval = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0
    let cancelled = false

    const promise = new Promise((resolve, reject) =>
      repeat(() => {
        if (cancelled) return true

        try {
          const result = fn()

          if (result) {
            resolve(result)

            // break the repeat() loop
            return true
          }
        } catch (e) {
          reject(e)
          return true
        }
      }, interval)
    )
    promise.cancel = () => (cancelled = true)

    return promise
  }

  // a partial structure of the video data defined in iwara's video page,
  // including only the properties we need

  const FILENAME_KEYWORDS = [
    "ID",
    "TITLE",
    "RES",
    "AUTHOR",
    "DATE",
    "UP_DATE",
    "DATE_TS",
    "UP_DATE_TS",
  ]
  const RESOLUTIONS = ["Source", "540p", "360p"]

  const autoEnabled =
    "browser" === GM_info.downloadMode
      ? (0, external_Vue_namespaceObject.ref)(storage.get("auto_down_enabled"))
      : false
  const filenameTemplate = (0, external_Vue_namespaceObject.ref)(storage.get("filename"))
  const resolution = (0, external_Vue_namespaceObject.ref)(storage.get("preferred_res"))

  const videoInfo = (0, external_Vue_namespaceObject.reactive)({
    id: "",
    title: "",
    author: "",
    created: 0,
    size: 0,
    error: "",
  })
  const sources = (0, external_Vue_namespaceObject.reactive)([])
  const source = (0, external_Vue_namespaceObject.computed)(
    () =>
      sources.find((_ref) => {
        let { label } = _ref
        return label === resolution.value
      }) || sources[0]
  )

  // indicates whether the sources belong to current page
  const hasFreshSources = (0, external_Vue_namespaceObject.ref)(false)

  const filename = (0, external_Vue_namespaceObject.computed)(() => {
    try {
      if (!source.value) throw "Please open a video"

      return resolveFilename(filenameTemplate.value, source.value)
    } catch (e) {
      return `Unable to resolve filename (${e.message || e})`
    }
  })

  ;(0, external_Vue_namespaceObject.watchEffect)(() =>
    storage.set("preferred_res", resolution.value)
  )
  ;(0, external_Vue_namespaceObject.watchEffect)(() =>
    storage.set("filename", filenameTemplate.value)
  )

  if ("boolean" !== typeof autoEnabled) {
    ;(0, external_Vue_namespaceObject.watchEffect)(() =>
      storage.set("auto_down_enabled", autoEnabled.value)
    )
    ;(0, external_Vue_namespaceObject.watchEffect)(() =>
      convertDownloadDropdown(void 0, autoEnabled.value, source.value)
    )
  }

  function useDownloaderSettings() {
    return {
      FILENAME_KEYWORDS,
      RESOLUTIONS,
      autoDownEnabled: autoEnabled,
      resolution,
      filenameTemplate,
      filenamePreview: filename,
    }
  }

  page("video", (pageID, onLeave) => {
    const videoActions = $(".page-video__actions").get(0)

    if (!videoActions) {
      log("Could not find video actions.")
      return
    }

    const actionsObserver = new SimpleMutationObserver((mutation) =>
      mutation.addedNodes.forEach((node) => {
        if (hasClass(node, "dropdown")) {
          updateVideoInfo(videoActions)
          updateSources(node)

          if (autoEnabled && autoEnabled.value) convertDownloadDropdown(node, true, source.value)
        }
      })
    )
    actionsObserver.observe(videoActions, { childList: true, immediate: true })

    onLeave(() => {
      // prevent unexpected downloads
      hasFreshSources.value = false

      actionsObserver.disconnect()
    })
  })

  function updateVideoInfo(videoActions) {
    try {
      // FIXME: reading the prop by a path is quite unreliable, any improvement?
      const video = getReactEventHandlers(videoActions).children[1].props.video

      videoInfo.id = video.id
      videoInfo.title = video.title
      videoInfo.created = new Date(video.createdAt).getTime()
      videoInfo.author = video.user.name
      videoInfo.size = video.file.size
    } catch (e) {
      log(e)
      videoInfo.error = e + ""
    }
  }

  function updateSources(downloadDropdown) {
    const newSources = $(downloadDropdown)
      .find(".dropdown__content a")
      .map(function () {
        const url = this.href
        const label = this.innerText

        return { url, label }
      })
      .get()

    if (!newSources.length) return

    sources.splice(0, sources.length, ...newSources)

    hasFreshSources.value = true
  }

  function convertDownloadDropdown(downloadDropdown, enabled, source) {
    // @ts-ignore The parameter is valid but TS doesn't recognize it
    const $dropdown = $(downloadDropdown || ".page-video__actions > .dropdown")
    const $button = $dropdown.find(".downloadButton")
    const rawButtonText = $button.text().replace(/\s*\(.*\)/, "")

    if (enabled) {
      if (!$dropdown.data("converted"))
        $dropdown
          .data("converted", true)
          .on("click", function () {
            download(this)
          })
          .children(".dropdown__content")
          .css("display", "none")

      $button.text(rawButtonText + (source ? ` (${source.label})` : ""))
    } else {
      $dropdown
        .data("converted", false)
        .off("click")
        .children(".dropdown__content")
        .css("display", "")
      $button.text(rawButtonText)
    }
  }

  function download(downloadDropdown) {
    try {
      if (!hasFreshSources.value) throw new Error("No sources found in current page.")
      if (!source.value) throw new Error("Missing source.")
      if ("browser" !== GM_info.downloadMode)
        throw new Error(`Invalid download mode "${GM_info.downloadMode}".`)

      const $downloadButton = $(downloadDropdown).find(".downloadButton")

      // TODO: properly disable the button
      $(downloadDropdown).css("pointer-events", "none")
      $downloadButton.css("background-color", "var(--primary-dark)")

      const filename = resolveFilename(filenameTemplate.value, source.value)

      log("Downloading:", filename, source.value.url)

      if (false);

      GM_download({
        url: source.value.url,
        name: filename,
        saveAs: true,
        onload: () => downloadEnded("onload"),
        onerror: (e) => downloadEnded("onerror", e),
        ontimeout: () => downloadEnded("ontimeout"),
      })

      function downloadEnded(type, e) {
        $(downloadDropdown).css("pointer-events", "")
        $downloadButton.css("background-color", "")

        if ("ontimeout" === type) e = { error: "timed_out" }

        if (e && e.error) {
          log(e)
          printDownloadMessage(
            `Download Error (${e.error}): ${(e.details && e.details.current) || "No info"}`
          )
        }
      }
    } catch (e) {
      log(e)
      printDownloadMessage(e + "")
    }
  }

  function resolveFilename(template, source) {
    if (videoInfo.error) throw new Error("Broken video info: " + videoInfo.error)

    const replacements = {
      ID: videoInfo.id,
      TITLE: videoInfo.title,
      RES: source.label,
      AUTHOR: videoInfo.author,
      DATE: formatDate(new Date()),
      DATE_TS: new Date().getTime() + "",
      UP_DATE: formatDate(new Date(videoInfo.created)),
      UP_DATE_TS: videoInfo.created + "",
    }

    let basename = template

    for (const [key, value] of Object.entries(replacements))
      basename = basename.replace(new RegExp(`{${key}}`, "g"), value)

    // strip characters disallowed in file path
    basename = basename.replace(/[*/:<>?\\|]/g, "")

    const ext = source.url.slice(source.url.lastIndexOf("."))

    return basename + ext
  }

  function printDownloadMessage(msg) {
    $(".page-video__bottom")
      .css("flex-wrap", "wrap")
      .append(`<div style="flex: 100% 0 0">${msg}</div>`)
  }

  const likeRateEnabled = (0, external_Vue_namespaceObject.ref)(storage.get("like_rates"))
  const highlightThreshold = (0, external_Vue_namespaceObject.ref)(
    storage.get("like_rate_highlight")
  )
  const highlightOpacity = (0, external_Vue_namespaceObject.ref)(
    storage.get("like_rate_highlight_opacity")
  )

  const likeRateClass = "enh-like-rate"

  ;(0, external_Vue_namespaceObject.watchEffect)(() => {
    storage.set("like_rates", likeRateEnabled.value)

    if (likeRateEnabled.value) document.body.classList.add("enh-show-like-rates")
    else document.body.classList.remove("enh-show-like-rates")
  })

  ;(0, external_Vue_namespaceObject.watchEffect)(() => {
    storage.set("like_rate_highlight", highlightThreshold.value)

    $(".videoTeaser, .imageTeaser")
      .parent()
      .each((i, teaser) => processTeaser(teaser))
  })

  ;(0, external_Vue_namespaceObject.watchEffect)(() => {
    storage.set("like_rate_highlight_opacity", highlightOpacity.value)

    document.body.style.setProperty("--ehg-hl-op", highlightOpacity.value + "")
  })

  function useTeaserSettings() {
    return { likeRateEnabled, highlightThreshold, highlightOpacity }
  }

  page(["home", "videoList", "imageList", "subscriptions"], async (pageID, onLeave) => {
    const teaserObserver = new SimpleMutationObserver((mutation) =>
      mutation.addedNodes.forEach(detectTeaser)
    )

    onLeave(() => {
      teaserObserver.disconnect()
    })

    const teaserBatcher = new TeaserBatcher()

    if ("home" === pageID) {
      const rows = $(".videoTeaser, .imageTeaser").closest(".row")

      if (!rows.length) {
        log("Could not find teaser rows.")
        return
      }

      rows.each((i, row) => detectRow(row))
    } else {
      const rowPromise = until(() => $(".videoTeaser, .imageTeaser").closest(".row")[0], 200)

      onLeave(() => rowPromise.cancel())

      detectRow(await rowPromise)
    }

    function detectRow(row) {
      teaserObserver.observe(row, { childList: true, immediate: true })
    }

    function detectTeaser(teaser) {
      if (isTeaser(teaser)) {
        teaserBatcher.add(teaser)
        teaserBatcher.run(processTeaser)
      }
    }
  })

  class TeaserBatcher {
    constructor() {
      this.teasers = []

      this.run = throttle((callback) => {
        let lastError

        try {
          this.teasers.forEach(callback)
        } catch (e) {
          // only record the last error so the console won't blow up
          lastError = e
        }

        if (lastError) log("Failed to process teasers", lastError)

        this.teasers.length = 0
      }, 0)
    }

    add(teaser) {
      this.teasers.push(teaser)
    }
  }

  function processTeaser(teaser) {
    const viewsLabel = $(teaser).find(".views")
    const likesLabel = $(teaser).find(".likes")

    let likePercentage

    const likeRateLabel = viewsLabel.children("." + likeRateClass)

    if (likeRateLabel.length) likePercentage = +likeRateLabel.text().trim().replace("%", "")
    else {
      let [views, likes] = [viewsLabel, likesLabel].map((icon) => {
        const value = icon.text().trim()

        return value.includes("k") ? 1e3 * +value.slice(0, -1) : +value
      })

      likePercentage = 0 === views ? 0 : Math.round((1e3 * likes) / views) / 10

      // prettier-ignore
      viewsLabel.children().eq(0).clone().addClass(likeRateClass).text(likePercentage+"%").appendTo(viewsLabel)
    }

    if (likePercentage >= highlightThreshold.value) teaser.classList.add("enh-highlight")
    else teaser.classList.remove("enh-highlight")
  }

  function isTeaser(node) {
    return (
      !!node.firstChild &&
      (hasClass(node.firstChild, "videoTeaser") || hasClass(node.firstChild, "imageTeaser"))
    )
  }

  // extracted by mini-css-extract-plugin
  /* harmony default export */ const Settings_module = {
    switch: "Settings-module__switch--qcsG",
    settings: "Settings-module__settings--alpJ",
    active: "Settings-module__active--iMRv",
    disabled: "Settings-module__disabled--vvjv",
    header: "Settings-module__header--s2Rw",
    title: "Settings-module__title--aDU_",
    view: "Settings-module__view--dY2E",
    sectionHeader: "Settings-module__section-header--Xy_I",
    fieldLabel: "Settings-module__field-label--O5EA",
    labelBlock: "Settings-module__label-block--EYVa",
    labelInline: "Settings-module__label-inline--v3DK",
    warn: "Settings-module__warn--KbCV",
  }

  // recommended vscode plugin for syntax highlighting: https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html
  // language=HTML
  const template = /* html */ `
    <div class='text text--text text--bold'>E</div>



    <div v-if='visible' :class='css.settings' @click.stop>
        <header :class='css.header'>
            <h2 :class='css.title'>{{ $t('name') }} v${GM_info.script.version}</h2>
        </header>
        <nav>
            <ul>
                <li
                    v-for='tab, i in tabs'
                    :class='{ [css.active]: i === tabIndex }'
                    @click='tabIndex = i'
                >
                    {{ $t(tab.name) }}
                </li>
            </ul>
        </nav>
        <div v-if='tabVal === "ui"' :class='css.view'>
            <h2 :class='css.sectionHeader'>{{ $t('s.ui.label') }}</h2>



            <h3 :class='css.fieldLabel'>{{ $t('s.ui.like_rate.label') }}</h3>
            <p v-html='$t("s.ui.like_rate.desc")'></p>
            <p>
                <label :class='css.labelBlock'>
                    {{ $t('s.enabled') }}
                    <input type='checkbox' v-model='likeRateEnabled'>
                </label>
            </p>



            <h3 :class='css.fieldLabel'>{{ $t('s.ui.highlight_threshold.label') }}</h3>
            <p v-html='$t("s.ui.highlight_threshold.desc")'></p>
            <p>
                <input type='number' step='0.1' min='0' max='100' :value='highlightThreshold' @change='highlightThreshold = +$event.target.value'>
            </p>
            <h3 :class='css.fieldLabel'>{{ $t('s.ui.highlight_bg.label') }}</h3>
            <p>
                <input type="range" min="0" max="1" step="0.01" v-model='highlightOpacity'>
            </p>
        </div>
        <div v-else-if='tabVal === "download"' :class='css.view'>
            <h2 :class='css.sectionHeader'>{{ $t('s.download.label') }}</h2>



            <h3 :class='css.fieldLabel'>{{ $t('s.download.auto.label') }}</h3>
            <p v-html='$t("s.download.auto.desc")'></p>
            <p v-if='!downloadMode' v-html='$t("s.download.auto.warn")'></p>
            <section v-else-if='downloadMode !== "browser"' :class='css.warn'>
                <p v-html='$tm("s.download.auto.warn_tm")[0]'></p>
                <ol>
                    <li v-for='line in $tm("s.download.auto.warn_tm").slice(1)'><p v-html='line'></p></li>
                </ol>
            </section>
            <p>
                <label :class='[css.labelBlock, { [css.disabled]: downloadMode !== "browser" }]'>
                    {{ $t('s.enabled') }}
                    <input type='checkbox' :disabled='downloadMode !== "browser"' v-model='autoDownEnabled'>
                </label>
            </p>



            <h3 :class='css.fieldLabel'>{{ $t('s.download.resolution.label') }}</h3>
            <p>
                <label v-for='res in RESOLUTIONS' :class='css.labelInline'>
                    <input type='radio' name='res' :value='res' v-model='resolution'>
                    {{ res }}
                </label>
            </p>



            <h3 :class='css.fieldLabel'>{{ $t('s.download.filename.label') }}</h3>
            <p v-html='$t("s.download.filename.desc")'></p>
            <div :class='css.keywords'>
                <table :class='css.keywordTable'>
                    <tr v-for='kw in FILENAME_KEYWORDS'>
                        <th>{{ kw }}</th>
                        <td>{{ $t('s.download.filename.key.' + kw.toLowerCase()) }}</td>
                    </tr>
                </table>
            </div>
            <input type='text' v-model='filenameTemplate'>
            <p>{{ $t('s.download.filename.preview') + ': ' + filenamePreview }}</p>
        </div>
        <div v-if='tabVal === "script"' :class='css.view'>
            <h2 :class='css.sectionHeader'>{{ $t('s.script.label') }}</h2>



            <h3 :class='css.fieldLabel'>{{ $t('s.script.language.label') }}</h3>
            <p>
                <label v-for='loc in $i18n.availableLocales' :class='css.labelBlock'>
                    <input type='radio' name='loc' :value='loc' :checked='activeLocale === loc' @change='locale = loc'>
                    {{ $t('language', loc) }}
                </label>
            </p>
        </div>
    </div>
`

  function setup() {
    const tabs = [
      { name: "s.ui.label", val: "ui" },
      { name: "s.download.label", val: "download" },
      { name: "s.script.label", val: "script" },
    ]
    const tabIndex = (0, external_Vue_namespaceObject.ref)(0)
    const tabVal = (0, external_Vue_namespaceObject.computed)(
      () => tabs[tabIndex.value] && tabs[tabIndex.value].val
    )
    const visible = (0, external_Vue_namespaceObject.ref)(false)

    const onClickContainer = () => {
      visible.value = !visible.value

      if (visible.value) onClickOutside(settingsContainer, () => (visible.value = false))
    }

    settingsContainer.addEventListener("click", onClickContainer)

    ;(0, external_Vue_namespaceObject.onBeforeUnmount)(() => {
      settingsContainer.removeEventListener("click", onClickContainer)
    })

    return {
      css: Settings_module,
      tabs,
      tabIndex,
      tabVal,
      visible,
      downloadMode: GM_info.downloadMode,
      ...useDownloaderSettings(),
      ...useConfigSettings(),
      ...useTeaserSettings(),
    }
  }

  const SETTINGS_ID = "enh-settings"

  const settingsContainer = $(
    `<div id="${SETTINGS_ID}" class='header__link ${Settings_module["switch"]}'></div>`
  )[0]

  let app

  page(ALL, (pageID, onLeave) => {
    const destination = $(".page .header__content:first-of-type .dropdown:last-of-type")[0]

    if (destination) {
      // destination element will be destroyed everytime the page changes,
      // so we need to insert the container after every page change
      destination.before(settingsContainer)

      // lazy-init the app
      if (!app) {
        app = (0, external_Vue_namespaceObject.createApp)({ template, setup })

        app.use(i18n_i18n)

        if (true)
          // pending fix https://github.com/vuejs/core/pull/5197
          // @ts-ignore
          unsafeWindow.Vue = Vue

        app.mount(settingsContainer)

        log("Settings view initialized")
      }
    } else log("Could not insert settings view: container not found.")
  })

  // prevent Sentry from tracking the logging
  setLogger(console.log.__sentry_original__ || console.log)

  const toggleButtonID = "enh-hide-options-btn"

  page(["videoList", "imageList"], (pageID, onLeave) => {
    const hideOptions = (0, external_Vue_namespaceObject.ref)(storage.get("hide_list_options"))
    const toggleText = (0, external_Vue_namespaceObject.computed)(() =>
      localize(hideOptions.value ? "ui.show_list_options" : "ui.hide_list_options")
    )

    const optionsContainer = $(".sortFilter").eq(0).closest(".col-lg-3")

    const toggleButton = $(
      `<button id="${toggleButtonID}" class="button button--primary button--ghost d-lg-none" type="button"></button>`
    )
      .insertBefore(optionsContainer)
      .on("click", () => (hideOptions.value = !hideOptions.value))

    ;(0, external_Vue_namespaceObject.watchEffect)(() => {
      storage.set("hide_list_options", hideOptions.value)
      optionsContainer.toggleClass("d-none", hideOptions.value)
    })

    ;(0, external_Vue_namespaceObject.watchEffect)(() => {
      toggleButton.text(toggleText.value)
    })
  })

  const state = (0, external_Vue_namespaceObject.reactive)({ theme: "light" })

  setInterval(() => {
    state.theme = localStorage.theme
  }, 1e3)

  ;(0, external_Vue_namespaceObject.watchEffect)(updateTheme)

  function updateTheme() {
    const theme = state.theme
    const adjustmentSign = "light" === theme ? -1 : 1
    const bodyColor = getComputedStyle(document.body).getPropertyValue("--body")

    document.body.style.setProperty(
      "--enh-body-focus",
      adjustHexColor(bodyColor, 15 * adjustmentSign)
    )
    document.body.style.setProperty(
      "--enh-body-highlight",
      adjustHexColor(bodyColor, 30 * adjustmentSign)
    )
  }

  async function main() {
    document.body.classList.add("enh-body")

    setupPaging()
  }

  main()
})()

GM_addStyle(`
.Settings-module__switch--qcsG {
  cursor: pointer;
}

.Settings-module__settings--alpJ {
  position: absolute;
  z-index: 1000;
  top: 100%;
  right: 0;
  width: 400px;
  background: var(--body);
  font-size: 14px;
  border: 2px solid var(--primary);
  border-top: none;
  cursor: default;
}

.Settings-module__settings--alpJ nav {
    padding: 0 16px;
    border-bottom: 1px solid var(--enh-body-highlight);
  }

.Settings-module__settings--alpJ nav ul {
      margin: 0;
      padding: 0;
      display: flex;
      flex-wrap: wrap;
    }

.Settings-module__settings--alpJ nav li {
      padding: 8px 16px;
      list-style-type: none;
      cursor: pointer;
    }

.Settings-module__settings--alpJ nav li:hover {
        background: var(--enh-body-focus);
      }

.Settings-module__settings--alpJ nav li.Settings-module__active--iMRv {
        background: var(--enh-body-highlight);
      }

.Settings-module__settings--alpJ p {
    color: var(--muted);
  }

.Settings-module__settings--alpJ p,
  .Settings-module__settings--alpJ section {
    margin-bottom: 8px;
  }

.Settings-module__settings--alpJ a {
    font-weight: bold;
    cursor: pointer;
  }

.Settings-module__settings--alpJ ol {
    padding-left: 20px;
  }

.Settings-module__settings--alpJ table {
    margin: 16px 0;
    width: 100%;
    background: var(--enh-body-focus);
    border: 1px solid var(--enh-body-highlight);
    border-collapse: collapse;
  }

.Settings-module__settings--alpJ th {
    text-align: right;
  }

.Settings-module__settings--alpJ th,
  .Settings-module__settings--alpJ td {
    padding: 4px 8px;
    border: 1px solid var(--enh-body-highlight);
  }

.Settings-module__settings--alpJ label {
    cursor: pointer;
  }

.Settings-module__settings--alpJ label:hover {
      background: var(--enh-body-focus);
    }

.Settings-module__settings--alpJ label input {
      cursor: pointer;
    }

.Settings-module__settings--alpJ label.Settings-module__disabled--vvjv {
      cursor: not-allowed;
    }

.Settings-module__settings--alpJ label.Settings-module__disabled--vvjv input {
        cursor: not-allowed;
      }

.Settings-module__settings--alpJ input[type="text"] {
    outline: none !important;
  }

.Settings-module__settings--alpJ input[type="text"] {
    margin-bottom: 16px;
    width: 100%;
    padding: 8px;
    background: var(--enh-body-focus);
    color: var(--text);
    border: 2px solid var(--enh-body-highlight);
    border-radius: 3px;
  }

.Settings-module__settings--alpJ input[type="text"]:hover,
    .Settings-module__settings--alpJ input[type="text"]:focus {
      background: var(--enh-body-highlight);
    }

.Settings-module__header--s2Rw {
  padding: 0 16px;
}

.Settings-module__title--aDU_ {
  margin-top: 4px;
}

.Settings-module__view--dY2E {
  padding: 16px;
}

.Settings-module__section-header--Xy_I {
  margin-bottom: 16px;
}

.Settings-module__field-label--O5EA {
  position: relative;
  margin: 16px 0;
  padding-top: 16px;
}

.Settings-module__field-label--O5EA:not(:first-of-type) {
    border-top: 1px solid var(--enh-body-highlight);
  }

.Settings-module__label-block--EYVa {
  display: flex;
  padding: 8px 8px 8px 0;
}

.Settings-module__label-block--EYVa input[type="checkbox"] {
    margin-left: auto;
  }

.Settings-module__label-inline--v3DK {
  padding: 8px 8px 8px 0;
}

.Settings-module__label-inline--v3DK:not(:first-child) {
    padding-left: 8px;
  }

.Settings-module__label-inline--v3DK:not(:last-child) {
    margin-right: 8px;
  }

.Settings-module__warn--KbCV {
  padding: 8px;
  background-color: #594c00;
}

.enh-body {
    --ehg-hl-op: 0.2;
}

#enh-settings {
    position: relative;
}

#enh-settings * {
        box-sizing: border-box;
    }

.enh-like-rate {
    display: none;
}

.enh-show-like-rates .videoTeaser .views .text, .enh-show-like-rates .imageTeaser .views .text {
                display: none;
            }

.enh-show-like-rates .videoTeaser .enh-like-rate.text, .enh-show-like-rates .imageTeaser .enh-like-rate.text {
            display: block;
        }

.enh-highlight:before {
        content: '';
        position: absolute;
        z-index: -1;
        top: -15px;
        bottom: 0;
        left: 0;
        right: 0;
        background: var(--primary);
        opacity: var(--ehg-hl-op);
    }

`)