Spotify Enhancer (Copy Track Info, ID & Link)

Add a button to copy track info, ID, and link.

  1. // ==UserScript==
  2. // @name Spotify Enhancer (Copy Track Info, ID & Link)
  3. // @description Add a button to copy track info, ID, and link.
  4. // @icon https://raw.githubusercontent.com/exyezed/spotify-enhancer/refs/heads/main/extras/spotify-enhancer.png
  5. // @version 1.4
  6. // @author exyezed
  7. // @namespace https://github.com/exyezed/spotify-enhancer/
  8. // @supportURL https://github.com/exyezed/spotify-enhancer/issues
  9. // @license MIT
  10. // @match https://open.spotify.com/*
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. const createSVG = (path, viewBox = "0 0 384 512", width = "16", height = "16", style = "cursor: pointer; margin-left: 8px; fill: #b3b3b3; vertical-align: middle") => {
  19. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  20. svg.setAttribute("viewBox", viewBox);
  21. svg.setAttribute("width", width);
  22. svg.setAttribute("height", height);
  23. svg.setAttribute("style", style);
  24. const pathElement = document.createElementNS("http://www.w3.org/2000/svg", "path");
  25. pathElement.setAttribute("d", path);
  26. svg.appendChild(pathElement);
  27. return svg;
  28. };
  29.  
  30. const copyIcon = createSVG("M192 0c-41.8 0-77.4 26.7-90.5 64L64 64C28.7 64 0 92.7 0 128L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64l-37.5 0C269.4 26.7 233.8 0 192 0zm0 64a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM72 272a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zm104-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zM72 368a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zm88 0c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16z");
  31. const successIcon = createSVG("M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z", "0 0 512 512", "16", "16", "cursor: pointer; margin-left: 8px; fill: #1ed760; vertical-align: middle");
  32. const errorIcon = createSVG("M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z", "0 0 512 512", "16", "16", "cursor: pointer; margin-left: 8px; fill: #f3727f; vertical-align: middle");
  33. const trackIdIcon = createSVG("M48 256C48 141.1 141.1 48 256 48c63.1 0 119.6 28.1 157.8 72.5c8.6 10.1 23.8 11.2 33.8 2.6s11.2-23.8 2.6-33.8C403.3 34.6 333.7 0 256 0C114.6 0 0 114.6 0 256l0 40c0 13.3 10.7 24 24 24s24-10.7 24-24l0-40zm458.5-52.9c-2.7-13-15.5-21.3-28.4-18.5s-21.3 15.5-18.5 28.4c2.9 13.9 4.5 28.3 4.5 43.1l0 40c0 13.3 10.7 24 24 24s24-10.7 24-24l0-40c0-18.1-1.9-35.8-5.5-52.9zM256 80c-19 0-37.4 3-54.5 8.6c-15.2 5-18.7 23.7-8.3 35.9c7.1 8.3 18.8 10.8 29.4 7.9c10.6-2.9 21.8-4.4 33.4-4.4c70.7 0 128 57.3 128 128l0 24.9c0 25.2-1.5 50.3-4.4 75.3c-1.7 14.6 9.4 27.8 24.2 27.8c11.8 0 21.9-8.6 23.3-20.3c3.3-27.4 5-55 5-82.7l0-24.9c0-97.2-78.8-176-176-176zM150.7 148.7c-9.1-10.6-25.3-11.4-33.9-.4C93.7 178 80 215.4 80 256l0 24.9c0 24.2-2.6 48.4-7.8 71.9C68.8 368.4 80.1 384 96.1 384c10.5 0 19.9-7 22.2-17.3c6.4-28.1 9.7-56.8 9.7-85.8l0-24.9c0-27.2 8.5-52.4 22.9-73.1c7.2-10.4 8-24.6-.2-34.2zM256 160c-53 0-96 43-96 96l0 24.9c0 35.9-4.6 71.5-13.8 106.1c-3.8 14.3 6.7 29 21.5 29c9.5 0 17.9-6.2 20.4-15.4c10.5-39 15.9-79.2 15.9-119.7l0-24.9c0-28.7 23.3-52 52-52s52 23.3 52 52l0 24.9c0 36.3-3.5 72.4-10.4 107.9c-2.7 13.9 7.7 27.2 21.8 27.2c10.2 0 19-7 21-17c7.7-38.8 11.6-78.3 11.6-118.1l0-24.9c0-53-43-96-96-96zm24 96c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 24.9c0 59.9-11 119.3-32.5 175.2l-5.9 15.3c-4.8 12.4 1.4 26.3 13.8 31s26.3-1.4 31-13.8l5.9-15.3C267.9 411.9 280 346.7 280 280.9l0-24.9z", "0 0 512 512", "16", "16", "margin-right: 8px; fill: #b3b3b3; vertical-align: middle");
  34. const trackLinkIcon = createSVG("M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C206.5 251.2 213 330 263 380c56.5 56.5 148 56.5 204.5 0L579.8 267.7zM60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5L217.7 177.2c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C433.5 260.8 427 182 377 132c-56.5-56.5-148-56.5-204.5 0L60.2 244.3z", "0 0 640 512", "16", "16", "margin-right: 8px; fill: #b3b3b3; vertical-align: middle");
  35. const enabledIcon = createSVG("M384 128c70.7 0 128 57.3 128 128s-57.3 128-128 128H192c-70.7 0-128-57.3-128-128s57.3-128 128-128H384zM576 256c0-106-86-192-192-192H192C86 64 0 150 0 256S86 448 192 448H384c106 0 192-86 192-192zM384 352c53 0 96-43 96-96s-43-96-96-96s-96 43-96 96s43 96 96 96z", "0 0 576 512", "16", "16", "margin-right: 8px; fill: currentColor; vertical-align: middle");
  36. const disabledIcon = createSVG("M192 128c-70.7 0-128 57.3-128 128s57.3 128 128 128H384c70.7 0 128-57.3 128-128s-57.3-128-128-128H192zM0 256C0 150 86 64 192 64H384c106 0 192 86 192 192s-86 192-192 192H192C86 448 0 362 0 256zm192 96c53 0 96-43 96-96s-43-96-96-96s-96 43-96 96s43 96 96 96z", "0 0 576 512", "16", "16", "margin-right: 8px; fill: currentColor; vertical-align: middle");
  37. const titleIcon = createSVG("M498.7 6c8.3 6 13.3 15.7 13.3 26l0 64c0 13.8-8.8 26-21.9 30.4L416 151.1 416 432c0 44.2-50.1 80-112 80s-112-35.8-112-80s50.1-80 112-80c17.2 0 33.5 2.8 48 7.7L352 128l0-64c0-13.8 8.8-26 21.9-30.4l96-32C479.6-1.6 490.4 0 498.7 6zM32 64l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96S14.3 64 32 64zm0 128l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 256c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z", "0 0 512 512", "16", "16", "margin-right: 8px; fill: #b3b3b3; vertical-align: middle");
  38. const artistIcon = createSVG("M224 0a128 128 0 1 1 0 256A128 128 0 1 1 224 0zM178.3 304l91.4 0c36.3 0 70.1 10.9 98.3 29.5l0 51.6c-18 2.5-34.8 9.1-48.5 19.4c-17.6 13.2-31.5 34-31.5 59.5c0 19.1 7.7 35.4 18.9 48L29.7 512C13.3 512 0 498.7 0 482.3C0 383.8 79.8 304 178.3 304zM630 164.5c6.3 4.5 10 11.8 10 19.5l0 48 0 160c0 1.2-.1 2.4-.3 3.6c.2 1.5 .3 2.9 .3 4.4c0 26.5-28.7 48-64 48s-64-21.5-64-48s28.7-48 64-48c5.5 0 10.9 .5 16 1.5l0-88.2-144 48L448 464c0 26.5-28.7 48-64 48s-64-21.5-64-48s28.7-48 64-48c5.5 0 10.9 .5 16 1.5L400 296l0-48c0-10.3 6.6-19.5 16.4-22.8l192-64c7.3-2.4 15.4-1.2 21.6 3.3z", "0 0 640 512", "16", "16", "margin-right: 8px; fill: #b3b3b3; vertical-align: middle");
  39. const optionsIcon = createSVG("M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z", "0 0 448 512", "16", "16", "margin-right: 8px; fill: #b3b3b3; vertical-align: middle");
  40.  
  41.  
  42. const defaultSettings = {
  43. copyType: 'trackId',
  44. isEnabled: true
  45. };
  46.  
  47. let settings = GM_getValue('spotifyCopySettings', defaultSettings);
  48.  
  49. function removeCopyButtons() {
  50. const copyButtons = document.querySelectorAll('.copy-track-info-btn');
  51. copyButtons.forEach(button => button.remove());
  52. }
  53.  
  54. function createMenuSeparator() {
  55. const separator = document.createElement('div');
  56. separator.style.height = '1px';
  57. separator.style.backgroundColor = '#404040';
  58. separator.style.margin = '8px 0';
  59. return separator;
  60. }
  61.  
  62. function getTrackId(row) {
  63. const trackLink = row.querySelector('a[href^="/track/"]');
  64. if (trackLink) {
  65. const trackId = trackLink.getAttribute('href').split('/').pop();
  66. return {
  67. id: trackId,
  68. link: `https://open.spotify.com/track/${trackId}`
  69. };
  70. }
  71. return null;
  72. }
  73.  
  74. function getTrackInfo(row) {
  75. let title, artist;
  76.  
  77. if (window.location.href.startsWith("https://open.spotify.com/artist/")) {
  78. const titleElement = row.querySelector('div[data-testid="tracklist-row"] div[role="gridcell"]:nth-child(2)');
  79. const artistElement = document.querySelector('span[data-testid="entityTitle"] h1');
  80.  
  81. title = titleElement ? titleElement.textContent.trim() : 'Title Not Found';
  82. artist = artistElement ? artistElement.textContent.trim() : 'Artist Not Found';
  83. } else {
  84. const titleElement = row.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line');
  85. const artistElement = row.querySelector('span.encore-text-body-small[data-encore-id="text"]');
  86.  
  87. title = titleElement ? titleElement.childNodes[0].textContent.trim() : 'Title Not Found';
  88. const artists = artistElement ? Array.from(artistElement.querySelectorAll('a')).map(el => el.textContent.trim()) : [];
  89. artist = artists.length > 0 ? artists.join(', ') : 'Artist Not Found';
  90. }
  91.  
  92. return {
  93. title,
  94. artist,
  95. titleFirst: `${title} - ${artist}`,
  96. artistFirst: `${artist} - ${title}`
  97. };
  98. }
  99.  
  100. function addCopyButton() {
  101. if (!settings.isEnabled) return;
  102. const trackRows = document.querySelectorAll('[data-testid="tracklist-row"]');
  103. trackRows.forEach(row => {
  104. if (row.querySelector('.copy-track-info-btn')) return;
  105. const copyBtn = document.createElement('span');
  106. copyBtn.className = 'copy-track-info-btn';
  107. copyBtn.style.display = 'inline-flex';
  108. copyBtn.style.alignItems = 'center';
  109. copyBtn.appendChild(copyIcon.cloneNode(true));
  110. copyBtn.onclick = function(e) {
  111. e.preventDefault();
  112. e.stopPropagation();
  113. const trackInfo = getTrackInfo(row);
  114. let textToCopy;
  115. let isSuccess = true;
  116. switch (settings.copyType) {
  117. case 'trackId':
  118. const trackIdInfo = getTrackId(row);
  119. if (trackIdInfo && trackIdInfo.id) {
  120. textToCopy = trackIdInfo.id;
  121. } else {
  122. textToCopy = 'Track ID Not Found';
  123. isSuccess = false;
  124. }
  125. break;
  126. case 'trackLink':
  127. const trackLinkInfo = getTrackId(row);
  128. if (trackLinkInfo && trackLinkInfo.link) {
  129. textToCopy = trackLinkInfo.link;
  130. } else {
  131. textToCopy = 'Track Link Not Found';
  132. isSuccess = false;
  133. }
  134. break;
  135. case 'titleFirst':
  136. textToCopy = trackInfo.titleFirst;
  137. break;
  138. case 'artistFirst':
  139. textToCopy = trackInfo.artistFirst;
  140. break;
  141. default:
  142. textToCopy = 'Invalid copy type';
  143. isSuccess = false;
  144. }
  145. navigator.clipboard.writeText(textToCopy).then(() => {
  146. this.replaceChild(isSuccess ? successIcon.cloneNode(true) : errorIcon.cloneNode(true), this.firstChild);
  147. setTimeout(() => {
  148. this.replaceChild(copyIcon.cloneNode(true), this.firstChild);
  149. }, 250);
  150. }).catch(() => {
  151. this.replaceChild(errorIcon.cloneNode(true), this.firstChild);
  152. setTimeout(() => {
  153. this.replaceChild(copyIcon.cloneNode(true), this.firstChild);
  154. }, 250);
  155. });
  156. };
  157. const compactContainer = row.querySelector('div[class="ft6dUifK4i03829TBAqC"]');
  158. const listContainer = row.querySelector('div[class="_iQpvk1c9OgRAc8KRTlH"]');
  159. if (compactContainer) {
  160. const explicitSpan = compactContainer.querySelector('.Ps9zgW56WZaBVLo1n3cg');
  161. if (explicitSpan) {
  162. const parentSpan = explicitSpan.closest('span[data-encore-id="text"]');
  163. parentSpan.after(copyBtn);
  164. } else {
  165. compactContainer.appendChild(copyBtn);
  166. }
  167. } else if (listContainer) {
  168. const textContainer = listContainer.querySelector('[data-encore-id="text"]');
  169. if (textContainer) {
  170. textContainer.style.display = 'flex';
  171. textContainer.style.alignItems = 'center';
  172. textContainer.appendChild(copyBtn);
  173. } else {
  174. listContainer.appendChild(copyBtn);
  175. }
  176. }
  177. });
  178. }
  179.  
  180. function createOptionsButton() {
  181. const actionBar = document.querySelector('.eSg4ntPU2KQLfpLGXAww[data-testid="action-bar-row"]');
  182. if (!actionBar || actionBar.querySelector('.spotify-copy-options')) return;
  183. const optionsBtn = document.createElement('button');
  184. optionsBtn.className = 'spotify-copy-options';
  185. optionsBtn.style.cssText = `
  186. background: transparent;
  187. border: none;
  188. color: #b3b3b3;
  189. cursor: pointer;
  190. display: flex;
  191. align-items: center;
  192. font-size: 14px;
  193. font-weight: 500;
  194. letter-spacing: 0.1em;
  195. text-transform: uppercase;
  196. transition: color 0.2s ease;
  197. `;
  198. optionsBtn.addEventListener('mouseover', () => {
  199. optionsBtn.style.color = '#ffffff';
  200. });
  201. optionsBtn.addEventListener('mouseout', () => {
  202. optionsBtn.style.color = '#b3b3b3';
  203. });
  204. const icon = optionsIcon.cloneNode(true);
  205. icon.style.cssText = `
  206. margin-right: 8px;
  207. width: 16px;
  208. height: 16px;
  209. fill: currentColor;
  210. vertical-align: middle;
  211. `;
  212. const optionsText = document.createElement('span');
  213. optionsText.textContent = 'OPTIONS';
  214. optionsText.style.cssText = `
  215. font-family: CircularSp, CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, var(--font-family,CircularSp,CircularSp-Arab,CircularSp-Hebr,CircularSp-Cyrl,CircularSp-Grek,CircularSp-Deva,sans-serif);
  216. letter-spacing: 1px;
  217. font-size: 14px;
  218. `;
  219. optionsBtn.appendChild(icon);
  220. optionsBtn.appendChild(optionsText);
  221. const menu = document.createElement('div');
  222. menu.className = 'spotify-copy-menu';
  223. menu.style.cssText = `
  224. display: none;
  225. position: absolute;
  226. background-color: #282828;
  227. padding: 4px;
  228. border-radius: 4px;
  229. z-index: 1000;
  230. box-shadow: 0 16px 24px rgba(0,0,0,.3),0 6px 8px rgba(0,0,0,.2);
  231. min-width: 196px;
  232. max-width: 350px;
  233. border: 1px solid rgba(255,255,255,0.1);
  234. `;
  235. const toggleOption = createMenuItem(settings.isEnabled ? 'Enabled' : 'Disabled',
  236. settings.isEnabled ? enabledIcon : disabledIcon,
  237. 'toggle');
  238. const separator = createMenuSeparator();
  239. const titleFirstOption = createMenuItem('Track Info (Title - Artist)', titleIcon, 'titleFirst');
  240. const artistFirstOption = createMenuItem('Track Info (Artist - Title)', artistIcon, 'artistFirst');
  241. const trackIdOption = createMenuItem('Track ID', trackIdIcon, 'trackId');
  242. const trackLinkOption = createMenuItem('Track Link', trackLinkIcon, 'trackLink');
  243. menu.appendChild(toggleOption);
  244. menu.appendChild(separator);
  245. menu.appendChild(titleFirstOption);
  246. menu.appendChild(artistFirstOption);
  247. menu.appendChild(trackIdOption);
  248. menu.appendChild(trackLinkOption);
  249. optionsBtn.addEventListener('click', (e) => {
  250. e.stopPropagation();
  251. menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
  252. const rect = optionsBtn.getBoundingClientRect();
  253. menu.style.top = `${rect.bottom + 5}px`;
  254. menu.style.left = `${rect.left}px`;
  255. });
  256. document.addEventListener('click', () => {
  257. menu.style.display = 'none';
  258. });
  259. const moreButton = actionBar.querySelector('[data-testid="more-button"]');
  260. if (moreButton) {
  261. moreButton.after(optionsBtn);
  262. }
  263. document.body.appendChild(menu);
  264. }
  265.  
  266. function createMenuItem(text, icon, value) {
  267. const item = document.createElement('div');
  268. item.className = 'spotify-copy-menu-item';
  269. item.style.padding = '8px';
  270. item.style.cursor = 'pointer';
  271. item.style.display = 'flex';
  272. item.style.alignItems = 'center';
  273.  
  274. if (value === 'toggle') {
  275. item.style.color = settings.isEnabled ? '#1ed760' : '#f3727f';
  276. } else {
  277. item.style.color = settings.copyType === value ? '#1ed760' : '#ffffff';
  278. }
  279.  
  280. item.appendChild(icon.cloneNode(true));
  281. const textSpan = document.createElement('span');
  282. textSpan.textContent = text;
  283. item.appendChild(textSpan);
  284.  
  285. item.addEventListener('mouseover', () => {
  286. item.style.backgroundColor = '#333333';
  287. });
  288.  
  289. item.addEventListener('mouseout', () => {
  290. item.style.backgroundColor = 'transparent';
  291. });
  292.  
  293. item.addEventListener('click', () => {
  294. if (value === 'toggle') {
  295. settings.isEnabled = !settings.isEnabled;
  296. item.style.color = settings.isEnabled ? '#1ed760' : '#f3727f';
  297. item.replaceChild(settings.isEnabled ? enabledIcon.cloneNode(true) : disabledIcon.cloneNode(true), item.firstChild);
  298. textSpan.textContent = settings.isEnabled ? 'Enabled' : 'Disabled';
  299.  
  300. if (!settings.isEnabled) {
  301. removeCopyButtons();
  302. } else {
  303. addCopyButton();
  304. }
  305. } else {
  306. settings.copyType = value;
  307. document.querySelectorAll('.spotify-copy-menu-item').forEach(menuItem => {
  308. if (!menuItem.textContent.includes('Enabled') && !menuItem.textContent.includes('Disabled')) {
  309. menuItem.style.color = menuItem.textContent.includes(text) ? '#1ed760' : '#ffffff';
  310. }
  311. });
  312. }
  313. GM_setValue('spotifyCopySettings', settings);
  314. });
  315.  
  316. return item;
  317. }
  318.  
  319. function initialize() {
  320. createOptionsButton();
  321. addCopyButton();
  322. }
  323.  
  324. const observer = new MutationObserver((mutations) => {
  325. for (const mutation of mutations) {
  326. if (mutation.addedNodes.length) {
  327. initialize();
  328. }
  329. }
  330. });
  331.  
  332. observer.observe(document.body, {
  333. childList: true,
  334. subtree: true
  335. });
  336.  
  337. initialize();
  338. })();