SimDevices Osu 谱面下载器插件

在 osu! 谱面下载页面上添加额外的按钮,可以唤醒下载器自动下载并导入谱面。

  1. // ==UserScript==
  2. // @name SimDevices Osu Beatmap Downloader Plugin
  3. // @name:zh-CN SimDevices Osu 谱面下载器插件
  4. // @include http*://osu.ppy.sh/*
  5. // @copyright 2020, Handle
  6. // @version 0.5.2
  7. // @description Add extra download buttons on beatmap page for SimDevices Beatmap Downloader on osu.ppy.sh
  8. // @description:zh-CN 在 osu! 谱面下载页面上添加额外的按钮,可以唤醒下载器自动下载并导入谱面。
  9. // @author Handle
  10. // @namespace https://github.com/SimDevices-Project
  11. // @supportURL https://github.com/SimDevices-Project/beatmap-downloader-user-script/issues
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15. ;(function () {
  16. 'use strict'
  17.  
  18. const styleClassName = '_beatmap_downloader_style_'
  19. const beapmapPageDownloadBtnClassName = '_beatmap_downloader_btn_'
  20.  
  21. const searchPageBtnClassName = `_beatmap_downloader_quick_btn_`
  22. const searchPageBtnText = 'Launch Downloader'
  23.  
  24. const searchPageDownloadTipClassName = `_beatmap_downloader_qtip_`
  25.  
  26. const insertStyle = () => {
  27. const styleDOM = document.createElement('style')
  28. styleDOM.classList.add(styleClassName)
  29. styleDOM.innerHTML = `
  30. .${searchPageBtnClassName}:hover {
  31. text-decoration: none!important;
  32. }
  33.  
  34. .${searchPageDownloadTipClassName}:after{
  35. display:block;
  36. content:'';
  37. border-width:8px 5px 8px 5px;
  38. border-style:solid;
  39. border-color:hsl(var(--base-hue),10%,10%) transparent transparent transparent;
  40.  
  41. position:absolute;
  42. left:calc(50% - 2px);
  43. top:100%;
  44. }
  45. `
  46. if (!document.querySelector(`.${styleClassName}`)) {
  47. document.head.append(styleDOM)
  48. }
  49. }
  50.  
  51. const formatURL = () => {
  52. const URL = document.URL
  53. const [domain, argument] = URL.substr(URL.indexOf('//') + 2).split('#')
  54. const pathname = window.location.pathname.substr(1)
  55. const [type, sid] = pathname.split('/')
  56. switch (type) {
  57. case 'beatmapsets':
  58. if (sid && sid.length) {
  59. const [mode, bid] = argument || []
  60. return {
  61. id: sid,
  62. type: 'beatmapset',
  63. }
  64. } else {
  65. return {
  66. id: 0,
  67. type: 'beatmapsearch',
  68. }
  69. }
  70. break
  71. default:
  72. return { id: 0, type: type }
  73. }
  74. }
  75.  
  76. const insertBeatmapPageDownloadBtn = (id = 1011011, type = 's') => {
  77. const htmlText = `
  78. <a href="beatmap-downloader://${type}/${id}" class="btn-osu-big btn-osu-big--beatmapset-header ${beapmapPageDownloadBtnClassName}">
  79. <span class="btn-osu-big__content">
  80. <span class="btn-osu-big__left">
  81. <span class="btn-osu-big__text-top">启动</span>
  82. <span class="btn-osu-hint btn-osu-big__text-bottom">Beatmap Downloader</span>
  83. </span>
  84. <span class="btn-osu-big__icon">
  85. <span class="fa-fw">
  86. <i class="fas fa-download"></i>
  87. </span>
  88. </span>
  89. </span>
  90. </a>`
  91. const htmlDOM = document.createRange().createContextualFragment(htmlText)
  92. const btnContainer = document.querySelector('.beatmapset-header__buttons')
  93. const downloaderBtnQueryWith = `.${beapmapPageDownloadBtnClassName}`
  94. const downloaderBtnDOM = document.querySelector(downloaderBtnQueryWith)
  95. if (!downloaderBtnDOM) {
  96. btnContainer.insertBefore(htmlDOM, btnContainer.lastElementChild)
  97. }
  98. }
  99.  
  100. const getSearchPageDownloadBtn = (id = 1011011, type = 's') => {
  101. const htmlText = `
  102. <a href="beatmap-downloader://${type}/${id}" class="beatmapset-panel__icon ${searchPageBtnClassName}" data-orig-title="Launch Downloader" aria-describedby="qtip-downloader">
  103. <i class="fas fa-lg fa-gamepad"></i>
  104. </a>`
  105. /**
  106. * @type {HTMLAnchorElement}
  107. */
  108. const htmlDOM = document.createRange().createContextualFragment(htmlText)
  109. return htmlDOM
  110. }
  111.  
  112. const insertSearchPageDownloadTip = () => {
  113. const insertHTML = `
  114. <div
  115. id="qtip-downloader"
  116. class="qtip qtip-default tooltip-default qtip-pos-bc ${searchPageDownloadTipClassName}"
  117. tracking="false"
  118. role="alert"
  119. aria-live="polite"
  120. aria-atomic="false"
  121. aria-describedby="qtip-downloader-content"
  122. aria-hidden="true"
  123. data-qtip-id="downloader"
  124. style="z-index: 15003;"
  125. >
  126. <div class="qtip-content" id="qtip-downloader-content" aria-atomic="true">
  127. <span style="display: block; visibility: visible;">${searchPageBtnText}</span>
  128. </div>
  129. </div>`
  130. /**
  131. * @type {HTMLDivElement}
  132. */
  133. const htmlDOM = document.createRange().createContextualFragment(insertHTML)
  134. const qTipQueryWith = `.${searchPageDownloadTipClassName}`
  135. const qTipDOM = document.querySelector(qTipQueryWith)
  136. if (!qTipDOM) {
  137. document.body.appendChild(htmlDOM)
  138. }
  139. }
  140.  
  141. /**
  142. * 获取DOM元素绝对坐标
  143. * @param {HTMLElement} element 要获取坐标的元素
  144. */
  145. const getAbsolutePostion = (element) => {
  146. const rect = element.getBoundingClientRect()
  147. const X = rect.left + document.documentElement.scrollLeft
  148. const Y = rect.top + document.documentElement.scrollTop
  149. const width = rect.width
  150. const height = rect.height
  151. return {
  152. x: X,
  153. y: Y,
  154. width,
  155. height,
  156. }
  157. }
  158.  
  159. const setSearchPageDownloadTipPosition = ({ x = 0, y = 0, show = true } = {}) => {
  160. const qTipQueryWith = `.${searchPageDownloadTipClassName}`
  161. /**
  162. * @type {HTMLDivElement}
  163. */
  164. const qTipDOM = document.querySelector(qTipQueryWith)
  165. if (!qTipDOM) {
  166. return
  167. }
  168. if (!show) {
  169. qTipDOM.style.display = 'none'
  170. qTipDOM.style.opacity = 0
  171. } else {
  172. if (qTipDOM.style.display === 'block') {
  173. } else {
  174. qTipDOM.style.display = 'block'
  175. let opacitySet = 0
  176. if (qTipDOM.dataset.timer) {
  177. clearInterval(qTipDOM.dataset.timer)
  178. }
  179. qTipDOM.style.opacity = opacitySet.toString(10)
  180. const easeIn = setInterval(() => {
  181. opacitySet += 0.2
  182. if (opacitySet >= 1) {
  183. opacitySet = 1
  184. clearInterval(easeIn)
  185. }
  186. qTipDOM.style.opacity = opacitySet.toString(10)
  187. }, 16.67)
  188. qTipDOM.dataset.timer = easeIn
  189. }
  190. }
  191. qTipDOM.style.left = `${x}px`
  192. qTipDOM.style.top = `${y}px`
  193. }
  194.  
  195. const insertSearchPageDownloadBtns = (target = document) => {
  196. /**
  197. * @type {NodeListOf<HTMLDivElement>}
  198. */
  199. const beatmapsetPannels = target.querySelectorAll('.beatmapset-panel')
  200. const addDownloadBtnToPannel = (beatmapsetPannel) => {
  201. if (beatmapsetPannel.querySelector(`.${searchPageBtnClassName}`)) {
  202. return
  203. }
  204. const audioURL = beatmapsetPannel.dataset.audioUrl
  205. const anchorLinkDOM = beatmapsetPannel.querySelectorAll('a')[0]
  206. const [domain, type, sid] = anchorLinkDOM.href.substr(anchorLinkDOM.href.indexOf('//') + 2).split('/')
  207. if (type !== 'beatmapsets') {
  208. return
  209. }
  210. const downloadBtn = getSearchPageDownloadBtn(sid, 's')
  211.  
  212. const iconBox = beatmapsetPannel.querySelector('.beatmapset-panel__icons-box')
  213. iconBox.insertBefore(downloadBtn, iconBox.lastElementChild)
  214.  
  215. const downloadBtnToBind = iconBox.querySelector(`.${searchPageBtnClassName}`)
  216.  
  217. let tipWidth = 0
  218. let tipHeight = 0
  219. const showTip = () => {
  220. const { x, y, width, height } = getAbsolutePostion(downloadBtnToBind)
  221. setSearchPageDownloadTipPosition({ x: x + width / 2 - tipWidth / 2, y: y - tipHeight - 8, show: true })
  222. if (!tipWidth || !tipHeight) {
  223. const qTipQueryWith = `.${searchPageDownloadTipClassName}`
  224. const qTipDOM = document.querySelector(qTipQueryWith)
  225. if (!qTipDOM) {
  226. return
  227. }
  228. const rect = getAbsolutePostion(qTipDOM)
  229. tipWidth = rect.width
  230. tipHeight = rect.height
  231.  
  232. showTip()
  233. }
  234. }
  235.  
  236. const hideTip = () => {
  237. setSearchPageDownloadTipPosition({ show: false })
  238. }
  239.  
  240. downloadBtnToBind.addEventListener('mousemove', showTip)
  241. downloadBtnToBind.addEventListener('mouseover', showTip)
  242. downloadBtnToBind.addEventListener('mouseenter', showTip)
  243.  
  244. downloadBtnToBind.addEventListener('mouseleave', hideTip)
  245. downloadBtnToBind.addEventListener('mouseout', hideTip)
  246. }
  247. beatmapsetPannels.forEach(addDownloadBtnToPannel)
  248. }
  249.  
  250. let timer = 0
  251. const loader = () => {
  252. const listenElementBeapmapPageQueryWith = '.js-react--beatmapset-page'
  253. const listenElementBeatmapPageDOM = document.querySelector(listenElementBeapmapPageQueryWith)
  254.  
  255. const listenElementBeapmapSearchQueryWith = '.js-react--beatmaps'
  256. const listenElementBeatmapSearchDOM = document.querySelector(listenElementBeapmapSearchQueryWith)
  257.  
  258. insertStyle()
  259.  
  260. if (listenElementBeatmapPageDOM && listenElementBeatmapPageDOM.dataset.reactTurbolinksLoaded === '1') {
  261. // 谱面详情
  262. const formated = formatURL()
  263. if (formated.type === 'beatmapset') {
  264. insertBeatmapPageDownloadBtn(formated.id, formated.type === 'beatmapset' ? 's' : 'b')
  265. }
  266. } else if (listenElementBeatmapSearchDOM && listenElementBeatmapSearchDOM.dataset.reactTurbolinksLoaded === '1') {
  267. // 搜索页面
  268. const formated = formatURL()
  269. if (formated.type === 'beatmapsearch') {
  270. insertSearchPageDownloadTip()
  271. const options = {
  272. childList: true,
  273. }
  274. const observer = new MutationObserver((mutationsList) => {
  275. mutationsList.forEach((mutation) => {
  276. switch (mutation.type) {
  277. case 'childList':
  278. if (mutation.addedNodes) {
  279. mutation.addedNodes.forEach(insertSearchPageDownloadBtns)
  280. }
  281. break
  282. }
  283. })
  284. })
  285. observer.observe(document.querySelector('.beatmapsets__items'), options)
  286. }
  287. } else {
  288. timer = requestAnimationFrame(loader)
  289. }
  290. }
  291. timer = requestAnimationFrame(loader)
  292. // 监听 turbolinks 渲染事件
  293. // https://greasyfork.org/zh-CN/scripts/3916-osu-my-download
  294. document.addEventListener('turbolinks:load', loader)
  295. })()