Lucida Downloader

Download music from Spotify, Qobuz, Tidal, Soundcloud, Deezer, Amazon Music and Yandex Music via Lucida. Adds download buttons and floating button.

当前为 2025-01-02 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Lucida Downloader
  3. // @description Download music from Spotify, Qobuz, Tidal, Soundcloud, Deezer, Amazon Music and Yandex Music via Lucida. Adds download buttons and floating button.
  4. // @icon https://raw.githubusercontent.com/afkarxyz/misc-scripts/refs/heads/main/lucida/lucida.png
  5. // @version 1.6
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/misc-scripts/
  8. // @supportURL https://github.com/afkarxyz/misc-scripts/issues
  9. // @license MIT
  10. // @match https://open.spotify.com/*
  11. // @match https://listen.tidal.com/*
  12. // @match https://music.yandex.com/*
  13. // @match https://music.amazon.com/*
  14. // @match https://www.deezer.com/*
  15. // @match https://soundcloud.com/*
  16. // @match https://www.qobuz.com/*
  17. // @match https://lucida.to/*
  18. // @match https://lucida.su/*
  19. // @grant GM_setValue
  20. // @grant GM_getValue
  21. // @grant GM_registerMenuCommand
  22. // @grant GM_addStyle
  23. // ==/UserScript==
  24.  
  25. (function() {
  26. 'use strict';
  27.  
  28. const DOMAINS = ['lucida.to', 'lucida.su'];
  29. const BASE_URL = 'https://raw.githubusercontent.com/afkarxyz/misc-scripts/refs/heads/main/lucida/';
  30. const LOGO_SVG = `<svg xml:space="preserve" width="48" height="28" viewBox="0 0 213.86 126.117" xmlns="http://www.w3.org/2000/svg"><g style="display:inline" transform="translate(-92.77 -153.171)"><ellipse class="st17" cx="199.7" cy="211.95" rx="103.93" ry="51" style="fill:#f42e8d;stroke:#fff;stroke-width:6;stroke-miterlimit:10"/><ellipse class="st18" cx="199.97" cy="211.95" rx="93.24" ry="41" style="fill:#f42e8d;stroke:#fff;stroke-width:3;stroke-miterlimit:10"/></g><path class="st19" style="fill:#fff;stroke:#fff;stroke-width:6;stroke-miterlimit:10" transform="translate(-92.77 -153.171)" d="M216.68 222.27v-8.79l2.1-2.21 5.4.25v10.75zM248.83 222.27v-8.79l2.1-2.21 5.4.25v10.75z"/><path class="st19" style="fill:#fff;stroke:#fff;stroke-width:6;stroke-miterlimit:10" transform="translate(-92.77 -153.171)" d="M216.68 223.56v-8.79l2.1-2.21 5.4.25v10.75zM125.12 237.48v-54.5l3.78-4.75h9.47v59.25zM139.86 204.18l3.75-3.28h7.36l-.18 16.25h9.01l.28-16.25h9.53l.25 36.58h-30zM171.18 204.4l3.65-3.5h23.6v12.19l-15.25.21v10.47l15.25-.05v13.76h-27.25zM199.97 200.9h12.5v36.59h-12.5z"/><path class="st19" style="fill:#fff;stroke:#fff;stroke-width:6;stroke-miterlimit:10" d="m214.09 204.41 4-3.51h14.44l-.25-17.14 3-5.53h9.92l.14 22.67v36.58h-31.25zM251.42 200.9h19.99l.25 4.25 3.49-4.25h6.35v36.58H247l-.25-32.08zM116.91 237.48v-54.5l3.78-4.75h9.47v59.25zM131.64 204.18l3.75-3.28h7.36l-.18 16.25h9.01l.29-16.25h9.52l.25 36.58h-30zM162.97 204.4l3.64-3.5h23.61v12.19l-15.25.21v10.47l15.25-.05v13.76h-27.25zM191.76 200.9h12.5v36.59h-12.5z" transform="translate(-92.77 -153.171)"/><path class="st19" style="fill:#fff;stroke:#fff;stroke-width:6;stroke-miterlimit:10" d="m205.88 204.41 4-3.51h14.43l-.25-17.14 3-5.53h9.93l.14 22.67v36.58h-31.25zM243.21 200.9h19.99l.24 4.25 3.5-4.25h6.34v36.58h-34.5l-.25-32.08zM162.97 204.4l3.64-3.5h23.61v12.19l-15.25.21v10.47l15.25-.05v13.76h-27.25zM131.64 204.18l3.75-3.28h7.36l-.18 16.25h9.01l.29-16.25h9.52l.25 36.58h-30z" transform="translate(-92.77 -153.171)"/><g transform="translate(-92.77 -153.171)"><path class="st19" style="fill:#fff;stroke:#fff;stroke-width:6;stroke-miterlimit:10" d="M216.68 222.27v-8.79l2.1-2.21 5.4.25v10.75zM248.83 222.27v-8.79l2.1-2.21 5.4.25v10.75z"/><path class="st19" style="fill:#fff;stroke:#fff;stroke-width:6;stroke-miterlimit:10" d="M216.68 223.56v-8.79l2.1-2.21 5.4.25v10.75z"/><circle class="st21" cx="279.8" cy="173.88" r="4.56" style="stroke:#fff;stroke-width:6;stroke-miterlimit:10"/><path class="st21" style="stroke:#fff;stroke-width:6;stroke-miterlimit:10" d="m132.88 255.72 2.83 4.71 5.1-1.26-3.34 4.17 2.83 4.71-4.9-2.13-3.35 4.17.32-5.49-4.91-2.14 5.1-1.25z"/><circle class="st21" cx="199.97" cy="236.84" r="5.62" style="stroke:#fff;stroke-width:6;stroke-miterlimit:10"/><ellipse class="st21" cx="184.25" cy="245.48" rx="3.38" ry="3.14" style="stroke:#fff;stroke-width:6;stroke-miterlimit:10"/><path class="st21" style="stroke:#fff;stroke-width:6;stroke-miterlimit:10" d="M216.68 223.77v-8.79l2.1-2.21 5.4.25v10.75zM248.83 223.77v-8.79l2.1-2.21 5.4.25v10.75zM194.48 175.23l4.06 3.92 4.72-2.6-2.21 5.03 4.06 3.92-5.43-.82-2.22 5.02-1.14-5.52-5.43-.82 4.73-2.6z"/></g><path class="st22" d="M21.66 110.899c-6.48-10.44 27.24-43.11 75.33-72.98 48.09-29.87 86.75-41.62 93.23-31.18 6.48 10.44-21.67 39.11-69.76 68.98-48.09 29.87-92.32 45.62-98.8 35.18z" style="fill:none;stroke:#fff;stroke-width:6;stroke-miterlimit:10"/><path class="st7" style="stroke:#000;stroke-width:3;stroke-miterlimit:10" d="M125.12 237.48v-54.5l3.78-4.75h9.47v59.25zM139.86 204.18l3.75-3.28h7.36l-.18 16.25h9.01l.28-16.25h9.53l.25 36.58h-30zM171.18 204.4l3.65-3.5h23.6v12.19l-15.25.21v10.47l15.25-.05v13.76h-27.25zM199.97 200.9h12.5v36.59h-12.5zM214.09 204.41l4-3.51h14.44l-.25-17.14 3-5.53h9.92l.14 22.67v36.58h-31.25zM251.42 200.9h19.99l.25 4.25 3.49-4.25h6.35v36.58H247l-.25-32.08z" transform="translate(-92.77 -153.171)"/><path class="st23" style="fill:#fff;stroke:#000;stroke-width:3;stroke-miterlimit:10" transform="translate(-92.77 -153.171)" d="m131.64 204.18 3.75-3.28h7.36l-.18 16.25h9.01l.29-16.25h9.52l.25 36.58h-30zM162.97 204.4l3.64-3.5h23.61v12.19l-15.25.21v10.47l15.25-.05v13.76h-27.25z"/><path class="st23" style="fill:#fff;stroke:#000;stroke-width:3;stroke-miterlimit:10" d="M98.99 47.729h12.5v36.59h-12.5z"/><path class="st23" style="fill:#fff;stroke:#000;stroke-width:3;stroke-miterlimit:10" transform="translate(-92.77 -153.171)" d="m205.88 204.41 4-3.51h14.43l-.25-17.14 3-5.53h9.93l.14 22.67v36.58h-31.25zM243.21 200.9h19.99l.24 4.25 3.5-4.25h6.34v36.58h-34.5l-.25-32.08z"/><circle class="st7" cx="190.62" cy="7.149" r="2.38" style="stroke:#000;stroke-width:3;stroke-miterlimit:10"/><circle class="st7" cx="187.03" cy="20.709" r="4.56" style="stroke:#000;stroke-width:3;stroke-miterlimit:10"/><path class="st7" style="stroke:#000;stroke-width:3;stroke-miterlimit:10" transform="translate(-92.77 -153.171)" d="m132.88 255.72 2.83 4.71 5.1-1.26-3.34 4.17 2.83 4.71-4.9-2.13-3.35 4.17.32-5.49-4.91-2.14 5.1-1.25z"/><circle class="st7" cx="107.2" cy="83.669" r="5.62" style="stroke:#000;stroke-width:3;stroke-miterlimit:10"/><ellipse class="st7" cx="91.48" cy="92.309" rx="3.38" ry="3.14" style="stroke:#000;stroke-width:3;stroke-miterlimit:10"/><path class="st7" style="stroke:#000;stroke-width:3;stroke-miterlimit:10" transform="translate(-92.77 -153.171)" d="m194.48 175.23 4.06 3.92 4.72-2.6-2.21 5.03 4.06 3.92-5.43-.82-2.22 5.02-1.14-5.52-5.43-.82 4.73-2.6zM248.83 223.77v-8.79l2.1-2.21 5.4.25v10.75zM216.68 223.77v-8.79l2.1-2.21 5.4.25v10.75z"/><path class="st9" d="M21.66 110.899c-6.48-10.44 27.24-43.11 75.33-72.98 48.09-29.87 86.75-41.62 93.23-31.18 6.48 10.44-21.67 39.11-69.76 68.98-48.09 29.87-92.32 45.62-98.8 35.18z" style="fill:none;stroke:#000;stroke-width:3;stroke-miterlimit:10"/><path class="st23" style="fill:#fff;stroke:#000;stroke-width:3;stroke-miterlimit:10" transform="translate(-92.77 -153.171)" d="m162.97 204.4 3.64-3.5h23.61v12.19l-15.25.21v10.47l15.25-.05v13.76h-27.25zM131.64 204.18l3.75-3.28h7.36l-.18 16.25h9.01l.29-16.25h9.52l.25 36.58h-30zM116.91 237.48v-54.5l3.78-4.75h9.47v59.25z"/></svg>`;
  31. const SERVICES = {
  32. '': { name: 'Disabled', icon: '' },
  33. 'spotify': {
  34. name: 'Spotify',
  35. icon: `${BASE_URL}spotify.png`
  36. },
  37. 'qobuz': {
  38. name: 'Qobuz',
  39. icon: `${BASE_URL}qobuz.png`
  40. },
  41. 'tidal': {
  42. name: 'Tidal',
  43. icon: `${BASE_URL}tidal.svg`
  44. },
  45. 'soundcloud': {
  46. name: 'Soundcloud',
  47. icon: `${BASE_URL}soundcloud.ico`
  48. },
  49. 'deezer': {
  50. name: 'Deezer',
  51. icon: `${BASE_URL}deezer.ico`
  52. },
  53. 'amazon': {
  54. name: 'Amazon Music',
  55. icon: `${BASE_URL}amazon.png`
  56. }
  57. };
  58.  
  59. GM_addStyle(`
  60. .lucida-modal *,
  61. .lucida-modal *::before,
  62. .lucida-modal *::after {
  63. all: initial;
  64. box-sizing: border-box;
  65. margin: 0;
  66. padding: 0;
  67. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !important;
  68. font-weight: normal !important;
  69. font-size: 14px !important;
  70. color: #333;
  71. }
  72. .lucida-modal-overlay {
  73. position: fixed;
  74. top: 0;
  75. left: 0;
  76. right: 0;
  77. bottom: 0;
  78. background: rgba(0, 0, 0, 0.7);
  79. display: flex;
  80. justify-content: center;
  81. align-items: center;
  82. z-index: 10000;
  83. font-weight: normal;
  84. }
  85. .lucida-modal {
  86. background: #fff;
  87. padding: 20px;
  88. border-radius: 8px;
  89. width: 400px;
  90. max-width: 90%;
  91. color: #333;
  92. font-size: 14px;
  93. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  94. font-weight: normal !important;
  95. }
  96. .lucida-modal h2 {
  97. margin: 0 0 20px;
  98. color: #f42e8d;
  99. font-size: 18px !important;
  100. font-weight: 600 !important;
  101. line-height: 1.4;
  102. }
  103. .lucida-modal .preference-group {
  104. margin-bottom: 20px;
  105. color: #333;
  106. }
  107. .lucida-modal label {
  108. display: block;
  109. margin-top: 20px;
  110. margin-bottom: 8px;
  111. font-weight: 600 !important;
  112. font-size: 14px !important;
  113. color: #333;
  114. }
  115.  
  116. .lucida-modal .header {
  117. display: flex;
  118. align-items: center;
  119. justify-content: flex-start;
  120. }
  121.  
  122. .lucida-modal .header img {
  123. width: 64px;
  124. height: 64px;
  125. object-fit: contain;
  126. }
  127.  
  128. .lucida-modal .header h2 {
  129. margin: 0;
  130. }
  131. .lucida-modal .preference-group label:first-child {
  132. margin-top: 0;
  133. }
  134. .lucida-modal select {
  135. -webkit-appearance: none;
  136. -moz-appearance: none;
  137. appearance: none;
  138. width: 100%;
  139. padding: 8px 32px 8px 12px;
  140. border: 1px solid #ddd;
  141. border-radius: 4px;
  142. background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E") calc(100% - 12px) center no-repeat;
  143. cursor: pointer;
  144. font-size: 14px !important;
  145. color: #333;
  146. }
  147. .lucida-modal select:hover {
  148. border-color: #f42e8d;
  149. }
  150. .lucida-modal select:focus {
  151. outline: none;
  152. border-color: #f42e8d;
  153. box-shadow: 0 0 0 2px rgba(244, 46, 141, 0.2);
  154. }
  155. .custom-options {
  156. scrollbar-width: thin;
  157. scrollbar-color: #f42e8d #f0f0f0;
  158. font-size: 14px !important;
  159. }
  160. .custom-options::-webkit-scrollbar {
  161. width: 8px;
  162. }
  163. .custom-options::-webkit-scrollbar-track {
  164. background: #f0f0f0;
  165. border-radius: 4px;
  166. }
  167. .custom-options::-webkit-scrollbar-thumb {
  168. background: #f42e8d;
  169. border-radius: 4px;
  170. }
  171. .custom-options::-webkit-scrollbar-thumb:hover {
  172. background: #d41d7a;
  173. }
  174. .service-select-wrapper {
  175. position: relative;
  176. margin-bottom: 15px;
  177. }
  178. .custom-select {
  179. width: 100%;
  180. padding: 8px 32px 8px 12px;
  181. border: 1px solid #ddd;
  182. border-radius: 4px;
  183. background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E") calc(100% - 12px) center no-repeat;
  184. display: flex;
  185. align-items: center;
  186. gap: 8px;
  187. cursor: pointer;
  188. transition: all 0.2s ease;
  189. user-select: none;
  190. font-size: 14px !important;
  191. color: #333;
  192. }
  193. .custom-select span {
  194. font-size: 14px !important;
  195. color: #333;
  196. }
  197. .custom-select:hover {
  198. border-color: #f42e8d;
  199. }
  200. .custom-options {
  201. position: absolute;
  202. top: 100%;
  203. left: 0;
  204. right: 0;
  205. background: white;
  206. border: 1px solid #ddd;
  207. border-radius: 4px;
  208. margin-top: 4px;
  209. max-height: 200px;
  210. overflow-y: auto;
  211. z-index: 1000;
  212. display: none;
  213. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  214. }
  215. .custom-options.show {
  216. display: block;
  217. }
  218. .service-option {
  219. display: flex;
  220. align-items: center;
  221. gap: 8px;
  222. padding: 8px 12px;
  223. cursor: pointer;
  224. transition: background-color 0.2s ease;
  225. font-weight: normal !important;
  226. font-size: 14px !important;
  227. color: #333;
  228. }
  229. .service-option span {
  230. font-size: 14px !important;
  231. color: #333;
  232. }
  233. .service-option:hover {
  234. background-color: #f5f5f5;
  235. }
  236. .service-option img,
  237. .custom-select img {
  238. width: 16px;
  239. height: 16px;
  240. object-fit: contain;
  241. }
  242. .lucida-modal .buttons {
  243. display: flex;
  244. justify-content: flex-end;
  245. gap: 10px;
  246. margin-top: 20px;
  247. }
  248. .lucida-modal button {
  249. padding: 8px 16px;
  250. border: none;
  251. border-radius: 4px;
  252. cursor: pointer;
  253. font-weight: 500;
  254. transition: all 0.2s ease;
  255. font-size: 14px !important;
  256. }
  257. .lucida-modal .save-btn {
  258. background: linear-gradient(135deg, #f42e8d, #b91c68);
  259. color: white;
  260. }
  261. .lucida-modal .save-btn:hover {
  262. background: linear-gradient(135deg, #ff3d9c, #d02077);
  263. transform: translateY(-1px);
  264. box-shadow: 0 2px 8px rgba(244, 46, 141, 0.4);
  265. }
  266. .lucida-modal .save-btn:active {
  267. transform: translateY(0);
  268. box-shadow: 0 1px 4px rgba(244, 46, 141, 0.4);
  269. }
  270. .lucida-modal .cancel-btn {
  271. background: #eee;
  272. color: #333;
  273. }
  274. .lucida-modal .cancel-btn:hover {
  275. background: #ddd;
  276. color: #333;
  277. transform: translateY(-1px);
  278. }
  279. .lucida-modal .cancel-btn:active {
  280. transform: translateY(0);
  281. }
  282. .floating-button {
  283. position: fixed;
  284. width: 80px;
  285. height: 80px;
  286. background-color: transparent;
  287. border-radius: 50%;
  288. display: flex;
  289. justify-content: center;
  290. align-items: center;
  291. cursor: move;
  292. z-index: 9999;
  293. opacity: 0.3;
  294. transition: opacity 0.3s ease;
  295. border: none;
  296. }
  297. .floating-button:hover {
  298. opacity: 1;
  299. }
  300. .floating-button svg {
  301. width: 48px;
  302. height: auto;
  303. cursor: pointer;
  304. }
  305. [role='grid'] {
  306. margin-left: 50px;
  307. }
  308. [data-testid="tracklist-row"] {
  309. position: relative;
  310. }
  311. [role="presentation"] > * {
  312. contain: unset;
  313. }
  314. .btn {
  315. width: 40px;
  316. height: 40px;
  317. border-radius: 50%;
  318. border: 0;
  319. position: relative;
  320. cursor: pointer;
  321. transition: all 0.2s ease;
  322. box-shadow: 0 2px 5px rgba(0,0,0,0.2);
  323. display: flex;
  324. align-items: center;
  325. justify-content: center;
  326. background: linear-gradient(135deg, #f42e8d, #b91c68);
  327. }
  328. .btn:hover {
  329. transform: scale(1.1);
  330. box-shadow: 0 4px 8px rgba(0,0,0,0.3);
  331. }
  332. .btn .icon {
  333. width: 50%;
  334. height: 50%;
  335. background-position: center;
  336. background-repeat: no-repeat;
  337. background-size: contain;
  338. background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="%23ffffff" d="M222.2 319.2c.5 .5 1.1 .8 1.8 .8s1.4-.3 1.8-.8L350.2 187.3c1.2-1.2 1.8-2.9 1.8-4.6c0-3.7-3-6.7-6.7-6.7L288 176c-8.8 0-16-7.2-16-16l0-120c0-4.4-3.6-8-8-8l-80 0c-4.4 0-8 3.6-8 8l0 120c0 8.8-7.2 16-16 16l-57.3 0c-3.7 0-6.7 3-6.7 6.7c0 1.7 .7 3.3 1.8 4.6L222.2 319.2zM224 352c-9.5 0-18.6-3.9-25.1-10.8L74.5 209.2C67.8 202 64 192.5 64 182.7c0-21.4 17.3-38.7 38.7-38.7l41.3 0 0-104c0-22.1 17.9-40 40-40l80 0c22.1 0 40 17.9 40 40l0 104 41.3 0c21.4 0 38.7 17.3 38.7 38.7c0 9.9-3.8 19.3-10.5 26.5L249.1 341.2c-6.5 6.9-15.6 10.8-25.1 10.8zM32 336l0 96c0 26.5 21.5 48 48 48l288 0c26.5 0 48-21.5 48-48l0-96c0-8.8 7.2-16 16-16s16 7.2 16 16l0 96c0 44.2-35.8 80-80 80L80 512c-44.2 0-80-35.8-80-80l0-96c0-8.8 7.2-16 16-16s16 7.2 16 16z"/></svg>');
  339. }
  340. [data-testid="tracklist-row"] .btn {
  341. position: absolute;
  342. top: 50%;
  343. right: 100%;
  344. margin-top: -20px;
  345. margin-right: 10px;
  346. }
  347. .N7GZp8IuWPJvCPz_7dOg .btn {
  348. width: 24px;
  349. height: 24px;
  350. transform-origin: center;
  351. position: absolute;
  352. top: 50%;
  353. right: 100%;
  354. margin-top: -12px !important;
  355. margin-right: 10px;
  356. }
  357. .N7GZp8IuWPJvCPz_7dOg .btn .icon {
  358. transform: scale(0.85);
  359. width: 65%;
  360. height: 65%;
  361. }
  362. `);
  363.  
  364. function createServiceOption(value, service) {
  365. const option = document.createElement('div');
  366. option.className = 'service-option';
  367. option.dataset.value = value;
  368. if (service.icon) {
  369. const img = document.createElement('img');
  370. img.src = service.icon;
  371. img.alt = service.name;
  372. img.style.display = 'none';
  373. img.onload = () => {
  374. img.style.display = 'inline';
  375. };
  376. option.appendChild(img);
  377. }
  378. const span = document.createElement('span');
  379. span.textContent = service.name;
  380. option.appendChild(span);
  381. return option;
  382. }
  383. function updateCustomSelect(customSelect, value) {
  384. const service = SERVICES[value];
  385. let content = `<span>${service.name}</span>`;
  386. if (service.icon) {
  387. const img = new Image();
  388. img.src = service.icon;
  389. img.style.display = 'none';
  390. img.onload = () => {
  391. img.style.display = 'inline';
  392. customSelect.querySelector('img')?.style.setProperty('display', 'inline');
  393. };
  394. content = `<img src="${service.icon}" alt="${service.name}" style="display: none;"><span>${service.name}</span>`;
  395. }
  396. customSelect.innerHTML = content;
  397. }
  398. function createPreferencesModal() {
  399. const existingModal = document.querySelector('.lucida-modal-overlay');
  400. if (existingModal) {
  401. existingModal.remove();
  402. }
  403. const modalHTML = `
  404. <div class="lucida-modal-overlay">
  405. <div class="lucida-modal">
  406. <div class="header">
  407. <h2>Lucida Preferences</h2>
  408. <img src="${BASE_URL}lucida.svg" alt="Lucida Icon" class="lucida-icon" style="cursor: pointer; margin-left: auto; display: none;">
  409. </div>
  410. <div class="preference-group">
  411. <label for="domain-select">Domain</label>
  412. <select id="domain-select">
  413. <option value="random">Random</option>
  414. <option value="lucida.to">Lucida.to</option>
  415. <option value="lucida.su">Lucida.su</option>
  416. </select>
  417. <label for="service-select">Service Resolver</label>
  418. <div class="service-select-wrapper">
  419. <div class="custom-select" id="custom-service-select">
  420. <img src="" alt="" style="display: none;">
  421. <span>Select a service</span>
  422. </div>
  423. <div class="custom-options">
  424. </div>
  425. </div>
  426. <input type="hidden" id="service-select">
  427. <label for="format-select">Download Format</label>
  428. <select id="format-select">
  429. <option value="original">Original Format (Highest Quality)</option>
  430. <option value="flac">FLAC</option>
  431. <option value="mp3">MP3</option>
  432. <option value="ogg-vorbis">OGG Vorbis</option>
  433. <option value="opus">Opus</option>
  434. <option value="m4a-aac">M4A AAC</option>
  435. <option value="wav">WAV</option>
  436. <option value="bitcrush">Bitcrush</option>
  437. </select>
  438.  
  439. <div id="quality-settings-container" style="display: none; margin-top: 20px;">
  440. <label for="quality-select" style="margin-top: 0;">Quality Settings</label>
  441. <select id="quality-select"></select>
  442. </div>
  443.  
  444. <label for="float-select">Float Icon</label>
  445. <select id="float-select">
  446. <option value="enabled">Enabled</option>
  447. <option value="disabled">Disabled</option>
  448. </select>
  449. </div>
  450. <div class="buttons">
  451. <button class="cancel-btn">Cancel</button>
  452. <button class="save-btn">Save</button>
  453. </div>
  454. </div>
  455. </div>
  456. `;
  457. const modalContainer = document.createElement('div');
  458. modalContainer.innerHTML = modalHTML;
  459. document.body.appendChild(modalContainer.firstElementChild);
  460. const customSelect = document.getElementById('custom-service-select');
  461. const customOptions = document.querySelector('.custom-options');
  462. const serviceSelect = document.getElementById('service-select');
  463. const domainSelect = document.getElementById('domain-select');
  464. const floatSelect = document.getElementById('float-select');
  465. const formatSelect = document.getElementById('format-select');
  466. const qualityContainer = document.getElementById('quality-settings-container');
  467. const qualitySelect = document.getElementById('quality-select');
  468. const lucidaIcon = document.querySelector('.lucida-icon');
  469. lucidaIcon.onload = () => {
  470. lucidaIcon.style.display = 'inline';
  471. };
  472. lucidaIcon.onerror = () => {
  473. lucidaIcon.style.display = 'none';
  474. };
  475. lucidaIcon.addEventListener('click', () => {
  476. const domainPref = GM_getValue('domainPreference', 'random');
  477. let domain = domainPref === 'random'
  478. ? DOMAINS[Math.floor(Math.random() * DOMAINS.length)]
  479. : domainPref;
  480. window.open(`https://${domain}/stats`, '_blank');
  481. });
  482. if (domainSelect) domainSelect.value = GM_getValue('domainPreference', 'random');
  483. if (floatSelect) floatSelect.value = GM_getValue('floatIconEnabled', 'enabled');
  484. if (formatSelect) formatSelect.value = GM_getValue('formatPreference', 'original');
  485. const savedService = GM_getValue('targetService', '');
  486. if (savedService && SERVICES[savedService]) {
  487. updateCustomSelect(customSelect, savedService);
  488. serviceSelect.value = savedService;
  489. }
  490. function updateQualityOptions(format) {
  491. qualitySelect.innerHTML = '';
  492. switch(format) {
  493. case 'flac':
  494. qualitySelect.innerHTML = '<option value="16">16-bit 44.1kHz</option>';
  495. qualityContainer.style.display = 'block';
  496. break;
  497. case 'mp3':
  498. case 'ogg-vorbis':
  499. case 'm4a-aac':
  500. qualitySelect.innerHTML = `
  501. <option value="320">320kb/s</option>
  502. <option value="256">256kb/s</option>
  503. <option value="192">192kb/s</option>
  504. <option value="128">128kb/s</option>
  505. `;
  506. qualityContainer.style.display = 'block';
  507. break;
  508. case 'opus':
  509. qualitySelect.innerHTML = `
  510. <option value="320">320kb/s</option>
  511. <option value="256">256kb/s</option>
  512. <option value="192">192kb/s</option>
  513. <option value="128">128kb/s</option>
  514. <option value="96">96kb/s</option>
  515. <option value="64">64kb/s</option>
  516. `;
  517. qualityContainer.style.display = 'block';
  518. break;
  519. default:
  520. qualityContainer.style.display = 'none';
  521. }
  522. }
  523. updateQualityOptions(formatSelect.value);
  524. if (qualitySelect) {
  525. qualitySelect.value = GM_getValue('qualityPreference', '320');
  526. }
  527. formatSelect.addEventListener('change', () => {
  528. updateQualityOptions(formatSelect.value);
  529. });
  530. Object.entries(SERVICES).forEach(([value, service]) => {
  531. const option = createServiceOption(value, service);
  532. customOptions.appendChild(option);
  533. option.addEventListener('click', () => {
  534. serviceSelect.value = value;
  535. updateCustomSelect(customSelect, value);
  536. customOptions.classList.remove('show');
  537. });
  538. });
  539. customSelect.addEventListener('click', () => {
  540. customOptions.classList.toggle('show');
  541. });
  542. document.addEventListener('click', (e) => {
  543. if (!e.target.closest('.service-select-wrapper')) {
  544. customOptions.classList.remove('show');
  545. }
  546. });
  547. const saveBtn = document.querySelector('.save-btn');
  548. if (saveBtn) {
  549. saveBtn.addEventListener('click', () => {
  550. if (domainSelect && serviceSelect && floatSelect && formatSelect && qualitySelect) {
  551. GM_setValue('domainPreference', domainSelect.value);
  552. GM_setValue('targetService', serviceSelect.value);
  553. GM_setValue('floatIconEnabled', floatSelect.value);
  554. GM_setValue('formatPreference', formatSelect.value);
  555. GM_setValue('qualityPreference', qualitySelect.value);
  556. const floatingButton = document.querySelector('.floating-button');
  557. if (floatingButton) {
  558. floatingButton.style.display = floatSelect.value === 'enabled' ? 'flex' : 'none';
  559. }
  560. }
  561. document.querySelector('.lucida-modal-overlay').remove();
  562. });
  563. }
  564. const cancelBtn = document.querySelector('.cancel-btn');
  565. if (cancelBtn) {
  566. cancelBtn.addEventListener('click', () => {
  567. document.querySelector('.lucida-modal-overlay').remove();
  568. });
  569. }
  570. const modalOverlay = document.querySelector('.lucida-modal-overlay');
  571. if (modalOverlay) {
  572. modalOverlay.addEventListener('click', (e) => {
  573. if (e.target === modalOverlay) {
  574. modalOverlay.remove();
  575. }
  576. });
  577. }
  578. }
  579.  
  580. function autoSelectFormat() {
  581. if (!window.location.hostname.includes('lucida.')) return;
  582.  
  583. const selectFormatAndQuality = () => {
  584. const convertSelect = document.getElementById('convert');
  585. if (!convertSelect) return;
  586.  
  587. const format = GM_getValue('formatPreference', 'original');
  588. const quality = GM_getValue('qualityPreference', '320');
  589.  
  590. convertSelect.value = format;
  591. convertSelect.dispatchEvent(new Event('change', { bubbles: true }));
  592.  
  593. const observer = new MutationObserver((mutations, obs) => {
  594. const downsettingSelect = document.getElementById('downsetting');
  595. if (downsettingSelect) {
  596. downsettingSelect.value = quality;
  597. downsettingSelect.dispatchEvent(new Event('change', { bubbles: true }));
  598. obs.disconnect();
  599. }
  600. });
  601.  
  602. observer.observe(document.body, { childList: true, subtree: true });
  603. };
  604.  
  605. if (document.getElementById('convert')) {
  606. selectFormatAndQuality();
  607. }
  608.  
  609. const pageObserver = new MutationObserver((mutations) => {
  610. if (document.getElementById('convert')) {
  611. selectFormatAndQuality();
  612. }
  613. });
  614.  
  615. pageObserver.observe(document.body, { childList: true, subtree: true });
  616. }
  617. function setupMenuCommand() {
  618. try {
  619. GM_registerMenuCommand('Lucida Preferences', () => {
  620. console.log('Opening preferences modal...');
  621. createPreferencesModal();
  622. });
  623. } catch (error) {
  624. console.error('Error registering menu command:', error);
  625. }
  626. }
  627. function openInLucida(trackUrl) {
  628. const currentUrl = encodeURIComponent(trackUrl || window.location.href);
  629. const prefs = getPreferences();
  630. let domain = prefs.domainPreference === 'random'
  631. ? DOMAINS[Math.floor(Math.random() * DOMAINS.length)]
  632. : prefs.domainPreference;
  633. let url = `https://${domain}/?url=${currentUrl}&country=auto`;
  634. if (prefs.targetService) {
  635. url += `&to=${prefs.targetService}`;
  636. }
  637. window.open(url, '_blank');
  638. }
  639. const getPreferences = () => ({
  640. targetService: GM_getValue('targetService', ''),
  641. domainPreference: GM_getValue('domainPreference', 'random')
  642. });
  643. function addButton(el) {
  644. const button = document.createElement('button');
  645. button.className = 'btn';
  646. const icon = document.createElement('div');
  647. icon.className = 'icon';
  648. button.appendChild(icon);
  649. el.appendChild(button);
  650. return button;
  651. }
  652. function addNowPlayingButton() {
  653. const downloadButton = document.createElement('button');
  654. downloadButton.className = 'Lucida-Button-sc-1dqy6lx-0 dmdXQN';
  655. downloadButton.innerHTML = '<span aria-hidden="true" class="IconWrapper__Wrapper-sc-16usrgb-0 hYdsxw"><svg data-encore-id="icon" role="img" aria-hidden="true" viewBox="0 0 448 512" class="Svg-sc-ytk21e-0 dYnaPI" width="24" height="24" fill="currentColor"><path d="M114.2 192L224 302 333.8 192 280 192c-13.3 0-24-10.7-24-24l0-120-64 0 0 120c0 13.3-10.7 24-24 24l-53.8 0zM224 352c-11.5 0-22.5-4.6-30.6-12.7L77.6 223.2C68.9 214.5 64 202.7 64 190.4c0-25.6 20.8-46.4 46.4-46.4l33.6 0 0-96c0-26.5 21.5-48 48-48l64 0c26.5 0 48 21.5 48 48l0 96 33.6 0c25.6 0 46.4 20.8 46.4 46.4c0 12.3-4.9 24.1-13.6 32.8L254.6 339.3c-8.1 8.1-19.1 12.7-30.6 12.7zM48 344l0 80c0 22.1 17.9 40 40 40l272 0c22.1 0 40-17.9 40-40l0-80c0-13.3 10.7-24 24-24s24 10.7 24 24l0 80c0 48.6-39.4 88-88 88L88 512c-48.6 0-88-39.4-88-88l0-80c0-13.3 10.7-24 24-24s24 10.7 24 24z"/></svg></span>';
  656. downloadButton.style.cssText = 'background:transparent;border:none;color:#f42e8d;cursor:pointer;padding:8px;margin:0 4px;transition:transform .2s ease';
  657. downloadButton.onmouseover = () => downloadButton.style.transform = 'scale(1.1)';
  658. downloadButton.onmouseout = () => downloadButton.style.transform = 'scale(1)';
  659. downloadButton.onclick = () => {
  660. const link = document.querySelector('a[href*="spotify:track:"]');
  661. if (link) {
  662. const match = link.getAttribute('href').match(/spotify:track:([a-zA-Z0-9]+)/);
  663. if (match) {
  664. const trackUrl = `https://open.spotify.com/track/${match[1]}`;
  665. openInLucida(trackUrl);
  666. }
  667. }
  668. };
  669. const container = document.querySelector('.snFK6_ei0caqvFI6As9Q')?.querySelector('.deomraqfhIAoSB3SgXpu');
  670. if (container && !container.querySelector('.Lucida-Button-sc-1dqy6lx-0')) {
  671. container.appendChild(downloadButton);
  672. }
  673. }
  674.  
  675. function animate() {
  676. const currentUrl = window.location.href;
  677. const urlParts = currentUrl.split('/');
  678. const type = urlParts[3];
  679. addNowPlayingButton();
  680. if (type === 'track') {
  681. const actionBarRow = document.querySelector('.eSg4ntPU2KQLfpLGXAww[data-testid="action-bar-row"]');
  682. if (actionBarRow && !actionBarRow.hasButtons) {
  683. const downloadButton = addButton(actionBarRow);
  684. downloadButton.onclick = function() {
  685. const spotifyId = urlParts[4].split('?')[0];
  686. openInLucida(`https://open.spotify.com/track/${spotifyId}`);
  687. }
  688. actionBarRow.hasButtons = true;
  689. }
  690. }
  691. if (type === 'artist') {
  692. const tracks = document.querySelectorAll('[role="gridcell"]');
  693. tracks.forEach(track => {
  694. if (!track.hasButtons) {
  695. const downloadButton = addButton(track);
  696. downloadButton.onclick = function() {
  697. const btn = track.querySelector('[data-testid="more-button"]');
  698. if (btn) {
  699. btn.click();
  700. setTimeout(() => {
  701. const highlightEl = document.querySelector('#context-menu a[href*="highlight"]');
  702. if (highlightEl) {
  703. const highlight = highlightEl.href.match(/highlight=(.+)/)[1];
  704. document.dispatchEvent(new MouseEvent('mousedown'));
  705. const spotifyId = highlight.split(':')[2];
  706. openInLucida(`https://open.spotify.com/track/${spotifyId}`);
  707. }
  708. }, 1);
  709. }
  710. }
  711. track.hasButtons = true;
  712. }
  713. });
  714. }
  715. if (type === 'album' || type === 'playlist' || type === 'track') {
  716. const tracks = document.querySelectorAll('[data-testid="tracklist-row"]');
  717. tracks.forEach(track => {
  718. if (!track.hasButtons) {
  719. const downloadButton = addButton(track);
  720. downloadButton.onclick = function() {
  721. const trackLink = track.querySelector('a[href^="/track"]');
  722. if (trackLink) {
  723. openInLucida(trackLink.href);
  724. } else {
  725. const btn = track.querySelector('[data-testid="more-button"]');
  726. if (btn) {
  727. btn.click();
  728. setTimeout(() => {
  729. const highlightEl = document.querySelector('#context-menu a[href*="highlight"]');
  730. if (highlightEl) {
  731. const highlight = highlightEl.href.match(/highlight=(.+)/)[1];
  732. document.dispatchEvent(new MouseEvent('mousedown'));
  733. const spotifyId = highlight.split(':')[2];
  734. openInLucida(`https://open.spotify.com/track/${spotifyId}`);
  735. }
  736. }, 1);
  737. }
  738. }
  739. }
  740. track.hasButtons = true;
  741. }
  742. });
  743. }
  744. }
  745. function animateLoop() {
  746. if (window.location.hostname === 'open.spotify.com') {
  747. animate();
  748. }
  749. requestAnimationFrame(animateLoop);
  750. }
  751. const button = document.createElement('button');
  752. button.className = 'floating-button';
  753. button.innerHTML = LOGO_SVG;
  754. const savedPosition = {
  755. left: GM_getValue('buttonLeft', '20'),
  756. top: GM_getValue('buttonTop', '20')
  757. };
  758. button.style.left = savedPosition.left + 'px';
  759. button.style.top = savedPosition.top + 'px';
  760. let isDragging = false;
  761. let startX, startY;
  762. button.addEventListener('mousedown', e => {
  763. if (e.target.tagName.toLowerCase() !== 'svg') {
  764. isDragging = true;
  765. startX = e.clientX - button.offsetLeft;
  766. startY = e.clientY - button.offsetTop;
  767. }
  768. });
  769. document.addEventListener('mousemove', e => {
  770. if (!isDragging) return;
  771. let left = e.clientX - startX;
  772. let top = e.clientY - startY;
  773. left = Math.max(0, Math.min(window.innerWidth - button.offsetWidth, left));
  774. top = Math.max(0, Math.min(window.innerHeight - button.offsetHeight, top));
  775. button.style.left = left + 'px';
  776. button.style.top = top + 'px';
  777. });
  778. document.addEventListener('mouseup', () => {
  779. if (!isDragging) return;
  780. isDragging = false;
  781. const SNAP = 20;
  782. const rect = button.getBoundingClientRect();
  783. if (rect.left < SNAP) button.style.left = '0px';
  784. if (rect.top < SNAP) button.style.top = '0px';
  785. if (window.innerWidth - rect.right < SNAP) button.style.left = (window.innerWidth - rect.width) + 'px';
  786. if (window.innerHeight - rect.bottom < SNAP) button.style.top = (window.innerHeight - rect.height) + 'px';
  787. GM_setValue('buttonLeft', button.style.left.replace('px', ''));
  788. GM_setValue('buttonTop', button.style.top.replace('px', ''));
  789. });
  790. button.addEventListener('click', e => {
  791. if (e.target.closest('svg')) {
  792. openInLucida();
  793. }
  794. });
  795. if (GM_getValue('floatIconEnabled', 'enabled') === 'disabled') {
  796. button.style.display = 'none';
  797. }
  798. document.body.appendChild(button);
  799. setupMenuCommand();
  800. requestAnimationFrame(animateLoop);
  801. autoSelectFormat();
  802. })();