Spotify Web - Copy track info to clipboard

Adds an entry in the context menu that copies the selected song name and artist to the clipboard

بۇ قوليازمىنى قاچىلاش؟
ئاپتورنىڭ تەۋسىيەلىگەن قوليازمىسى

سىز بەلكىم Spotify Genius Lyrics نى ياقتۇرۇشىڭىز مۇمكىن.

بۇ قوليازمىنى قاچىلاش
  1. // ==UserScript==
  2. // @name Spotify Web - Copy track info to clipboard
  3. // @name:es Spotify Web - Copiar info de la canción
  4. // @name:pt Spotify Web - Copiar info da canción
  5. // @name:it Spotify Web - Copia l'informazione sul brano
  6. // @name:fr Spotify Web - Copier les informations de titre
  7. // @name:zh-TW Spotify Web - 複製歌曲信息
  8. // @name:zh-CN Spotify Web - 复制歌曲信息
  9. // @name:zh Spotify Web - 复制歌曲信息
  10. // @name:ar Spotify Web - انسخ معلومات الأغنية
  11. // @name:iw Spotify Web - העתקת מידע השיר
  12. // @name:ru Spotify Web - Копировать данные трека
  13. // @name:id Spotify Web - Salin Informasi Lagu
  14. // @name:ms Spotify Web - Salin Maklumat Lagu
  15. // @name:de Spotify Web - Songinformation kopieren
  16. // @name:ja Spotify Web - 曲情報をコピー
  17. // @name:pl Spotify Web - Skopiuj informacje o utworze
  18. // @name:cs Spotify Web - Kopírovat informace o skladbě
  19. // @name:el Spotify Web - Αντιγραφή πληροφοριών τραγουδιού
  20. // @name:hu Spotify Web - Dal adat másolása
  21. // @name:tr Spotify Web - Şarkı Bilgilerini Kopyala
  22. // @name:th Spotify Web - คัดลอกข้อมูลเพลง
  23. // @name:vi Spotify Web - Sao chép Thông tin Bài hát
  24. // @name:sv Spotify Web - Kopiera sånginfoen
  25. // @name:nl Spotify Web - Info van nummer kopiëren
  26. // @description Adds an entry in the context menu that copies the selected song name and artist to the clipboard
  27. // @description:es Agrega una entrada en el menú contextual que copia el nombre de la canción y el artista seleccionados al portapapeles
  28. // @description:pt Adiciona uma entrada no menu de contexto que copia o nome da música selecionada e o artista para a área de transferência
  29. // @description:it Aggiunge una voce nel menu contestuale che copia il nome del brano e l'artista selezionati negli appunti
  30. // @description:fr Ajoute une entrée dans le menu contextuel qui copie le nom de la chanson et l'artiste sélectionnés dans le presse-papiers
  31. // @description:zh-TW 在上下文菜單中添加一個條目,該條目將選定的歌曲名稱和歌手複製到剪貼板
  32. // @description:zh-CN 在上下文菜单中添加一个条目,将选定的歌曲名称和歌手复制到剪贴板
  33. // @description:zh 在上下文菜单中添加一个条目,将选定的歌曲名称和歌手复制到剪贴板
  34. // @description:ar أضف إدخالاً في قائمة السياق ينسخ اسم الأغنية والفنان المحدد إلى الحافظة
  35. // @description:iw הוסף ערך בתפריט ההקשר שמעתיק ללוח הלוח את שם השיר והאמן שנבחרו
  36. // @description:ru Добавить пункт контекстного меню, копирующий имя выбранной песни и исполнителя в буфер обмена.
  37. // @description:id Tambahkan entri menu konteks yang menyalin nama lagu dan artis yang dipilih ke clipboard
  38. // @description:ms Tambahkan entri menu konteks yang menyalin nama lagu dan artis yang dipilih ke papan keratan
  39. // @description:de Fügt einen Eintrag im Kontextmenü hinzu, der den ausgewählten Songnamen und Interpreten in die Zwischenablage kopiert
  40. // @description:ja 選択した曲名とアーティストをクリップボードにコピーするエントリをコンテキストメニューに追加します
  41. // @description:pl Dodaje wpis w menu kontekstowym, który kopiuje wybrany tytuł utworu i wykonawcę do schowka
  42. // @description:cs Přidá položku do místní nabídky, která zkopíruje název vybrané skladby a umělce do schránky
  43. // @description:el Προσθέτει μια καταχώριση στο μενού περιβάλλοντος που αντιγράφει το επιλεγμένο όνομα τραγουδιού και τον καλλιτέχνη στο πρόχειρο
  44. // @description:hu Hozzáad egy bejegyzést a helyi menübe, amely átmásolja a kiválasztott dal nevét és előadót a vágólapra
  45. // @description:tr Bağlam menüsüne seçili şarkı adını ve sanatçıyı panoya kopyalayan bir giriş ekler
  46. // @description:th เพิ่มรายการในเมนูบริบทที่คัดลอกชื่อเพลงและศิลปินที่เลือกไปยังคลิปบอร์ด
  47. // @description:vi Thêm một mục vào menu ngữ cảnh để sao chép tên bài hát và nghệ sĩ đã chọn vào khay nhớ tạm
  48. // @description:sv Lägger till en post i snabbmenyn som kopierar det valda låtnamnet och artisten till Urklipp
  49. // @description:nl Voegt een item toe aan het contextmenu dat de geselecteerde songnaam en artiest naar het klembord kopieert
  50. // @namespace https://openuserjs.org/users/cuzi
  51. // @icon https://open.spotify.com/favicon.ico
  52. // @version 25
  53. // @license MIT
  54. // @copyright 2020, cuzi (https://openuserjs.org/users/cuzi)
  55. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js
  56. // @grant GM.setClipboard
  57. // @grant GM_setClipboard
  58. // @match https://open.spotify.com/*
  59. // @sandbox JavaScript
  60. // ==/UserScript==
  61.  
  62. // ==OpenUserJS==
  63. // @author cuzi
  64. // ==/OpenUserJS==
  65.  
  66. /* globals $, GM, GM_setClipboard, MouseEvent */
  67. /* jshint asi: true, esversion: 8 */
  68.  
  69. 'use strict';
  70.  
  71. (function () {
  72. const translations = {
  73. es: ['Copiar info de la canción', 'Copiado: %s'],
  74. pt: ['Copiar info da canción', 'Copiado: %s'],
  75. it: ['Copia l\'informazione', 'Copiato: %s'],
  76. fr: ['Copier les informations de titre', '%s copié'],
  77. 'zh-HK': ['複製歌曲信息', '已復制: %s'],
  78. 'zh-TW': ['複製歌曲信息', '已復制: %s'],
  79. zh: ['复制歌曲信息', '已複製: %s'],
  80. ar: ['انسخ معلومات الأغنية', '%s :تمّ نسخ'],
  81. iw: ['העתקת מידע השיר', '%s :הועתק'],
  82. ru: ['Копировать данные трека', 'Скопирована: %s'],
  83. id: ['Salin Informasi Lagu', 'Disalin: %s'],
  84. ms: ['Salin Maklumat Lagu', 'Disalin: %s'],
  85. de: ['Songinformation kopieren', '%s kopiert'],
  86. ja: ['曲情報をコピー', '%s をコピーしました'],
  87. pl: ['Skopiuj informacje o utworze', '%s skopiowano'],
  88. cs: ['Kopírovat informace o skladbě', '%s byl zkopírován'],
  89. el: ['Αντιγραφή πληροφοριών τραγουδιού', '%s αντιγράφηκε'],
  90. hu: ['Dal adat másolása', '%s másolva'],
  91. tr: ['Şarkı Bilgilerini Kopyala', '%s kopyalandı'],
  92. th: ['คัดลอกข้อมูลเพลง', '%s ไปที่คลิปบอร์ดแล้ว'],
  93. vi: ['Sao chép Thông tin Bài hát', '%s đã được sao chép'],
  94. sv: ['Kopiera sånginfoen', '%s kopierad'],
  95. nl: ['Info van nummer kopiëren', '%s gekopieerd'],
  96. en: ['Copy track info', 'Copied: %s']
  97. }
  98. let [menuString, copiedString] = translations.en
  99.  
  100. const htmlTag = document.querySelector('html[lang]')
  101. if (htmlTag && htmlTag.lang in translations) {
  102. [menuString, copiedString] = translations[htmlTag.lang]
  103. } else {
  104. for (const lang in translations) {
  105. if (navigator.language.startsWith(lang)) {
  106. [menuString, copiedString] = translations[lang]
  107. break
  108. }
  109. }
  110. }
  111.  
  112. let showInfoID
  113. const showInfo = function (str) {
  114. window.clearTimeout(showInfoID)
  115. if (!document.getElementById('copied_song_info_outer')) {
  116. document.head.appendChild(document.createElement('style')).innerHTML = `
  117.  
  118. #copied_song_info_outer {
  119. margin: -32px calc(var(--panel-gap)*-1) 0;
  120. display: grid;
  121. grid-area: 1/1/now-playing-bar-start/-1;
  122. pointer-events: none;
  123. position: relative;
  124. z-index: 5;
  125. }
  126.  
  127. #copied_song_info_inner {
  128. margin-bottom: 16px;
  129. place-self: end center;
  130. pointer-events: none;
  131. z-index: 100;
  132. }
  133.  
  134. #copied_song_info_text {
  135. background: #2e77d0;
  136. border-radius: 8px;
  137. -webkit-box-shadow: 0 4px 12px 4px rgba(0,0,0,.5);
  138. box-shadow: 0 4px 12px 4px rgba(0,0,0,.5);
  139. color: #fff;
  140. display: inline-block;
  141. font-size: 16px;
  142. line-height: 20px;
  143. max-width: 450px;
  144. padding: 12px 36px;
  145. text-align: center;
  146. -webkit-transition: none .5s cubic-bezier(.3,0,.4,1);
  147. transition: none .5s cubic-bezier(.3,0,.4,1);
  148. transition-property: none;
  149. -webkit-transition-property: opacity;
  150. transition-property: opacity;
  151. }
  152. `
  153.  
  154. const node = $('<div id="copied_song_info_outer"><div id="copied_song_info_inner"><div id="copied_song_info_text"></div></div></div>')
  155.  
  156. if (document.querySelector('.Root footer')) {
  157. $('.Root footer').parent().after(node)
  158. } else {
  159. node.appendTo('.Root')
  160. }
  161. }
  162. const copiedSongInfoOuter = $('#copied_song_info_outer')
  163. const copiedSongInfoText = $('#copied_song_info_text')
  164.  
  165. copiedSongInfoOuter.css('display', 'grid')
  166. copiedSongInfoText.css('opacity', 1)
  167. copiedSongInfoText.html(str.replace('\n', '<br>\n'))
  168.  
  169. showInfoID = window.setTimeout(function () {
  170. copiedSongInfoText.css('opacity', 0)
  171. showInfoID = window.setTimeout(function () {
  172. copiedSongInfoOuter.css('display', 'none')
  173. }, 700)
  174. }, 4000)
  175. }
  176.  
  177. const getSongTitle = function ($titlenodes) {
  178. let titleText
  179.  
  180. if ($titlenodes && $titlenodes.length > 0) {
  181. titleText = $titlenodes.text()
  182. if (titleText && titleText.trim()) {
  183. return titleText.trim()
  184. }
  185. }
  186.  
  187. if ($('.track-info__name').length > 0) {
  188. titleText = $('.track-info__name')[0].innerText
  189. if (titleText && titleText.trim()) {
  190. return titleText.trim()
  191. }
  192. }
  193.  
  194. return ''
  195. }
  196.  
  197. const getArtistName = function ($artistnodes) {
  198. let artistText
  199.  
  200. if (typeof $artistnodes === 'string') {
  201. return $artistnodes.trim()
  202. }
  203.  
  204. if ($artistnodes) {
  205. const artistTextNodes = $artistnodes.not((i, e) => e.className)
  206. if (artistTextNodes.length === 1) {
  207. artistText = artistTextNodes.text()
  208. if (artistText && artistText.trim()) {
  209. return artistText.trim()
  210. }
  211. } else if (artistTextNodes.length > 1) {
  212. artistText = artistTextNodes.map((i, e) => e.textContent.trim()).get()
  213. artistText = artistText.join(', ')
  214. return artistText.trim()
  215. }
  216.  
  217. // In playlist:
  218. if ($artistnodes.find('.ellipsis-one-line').length > 0) {
  219. artistText = $artistnodes.find('.ellipsis-one-line')[0].innerText
  220. if (artistText && artistText.trim()) {
  221. return artistText.trim()
  222. }
  223. }
  224. if ($artistnodes.find('.standalone-ellipsis-one-line').length > 0) {
  225. artistText = $artistnodes.find('.standalone-ellipsis-one-line')[0].innerText
  226. if (artistText && artistText.trim()) {
  227. return artistText.trim()
  228. }
  229. }
  230.  
  231. // Something else, just accumulate all artist links: <a href="/artist/ARTISTID">Artistname</a>
  232. if ($artistnodes.find('a[href*="/artist/"]').length > 0) {
  233. return $.map($artistnodes.find('a[href*="/artist/"]'), (element) => $(element).text().trim()).join(', ')
  234. }
  235. }
  236.  
  237. if (document.location.pathname.startsWith('/artist/')) {
  238. if ($('.content.artist>div h1').length > 0) {
  239. artistText = $('.content.artist>div h1')[0].textContent
  240. if (artistText && artistText.trim()) {
  241. return artistText.trim()
  242. }
  243. } else {
  244. if ($('.Root main .contentSpacing [data-testid="adaptiveEntityTitle"]').length > 0) {
  245. artistText = $('.Root main .contentSpacing [data-testid="adaptiveEntityTitle"]')[0].textContent
  246. if (artistText && artistText.trim()) {
  247. return artistText.trim()
  248. }
  249. }
  250. }
  251. }
  252.  
  253. if (document.location.pathname.startsWith('/album/')) {
  254. artistText = document.querySelector('.os-content h1').textContent
  255. if (artistText && artistText.trim()) {
  256. return artistText.trim()
  257. }
  258. }
  259.  
  260. if ($('.track-info__artists').length > 0) {
  261. artistText = $('.track-info__artists')[0].innerText
  262. if (artistText && artistText.trim()) {
  263. return artistText.trim()
  264. }
  265. }
  266.  
  267. return ''
  268. }
  269.  
  270. const findSongInfo = function ($row) {
  271. console.debug('findSongInfo for', $row[0])
  272. let title = $row.find('.tracklist-name')
  273. if (title.length === 0 && $row.find('a[href*="/track/"]').length > 0) {
  274. title = $($row.find('a[href*="/track/"]')[0])
  275. }
  276. if (title.length === 0) {
  277. title = $row.find('div[data-testid="tracklist-row"] .standalone-ellipsis-one-line')
  278. }
  279. if (title.length === 0) {
  280. title = $row.find('div[role="gridcell"] img').parent().find('.standalone-ellipsis-one-line')
  281. }
  282. if (title.length === 0 && $row.hasClass('now-playing')) {
  283. title = $row.find('.ellipsis-one-line>.ellipsis-one-line').eq(0)
  284. }
  285. let artist = $row.find('.artists-album span')
  286. if (artist.length === 0 && $row.hasClass('now-playing')) {
  287. artist = $row.find('.ellipsis-one-line>.ellipsis-one-line').eq(1)
  288. }
  289. if (artist.length === 0 && title.length === 0 && $row.find('[data-testid="nowplaying-track-link"]')) {
  290. title = $row.find('[data-testid="nowplaying-track-link"]')
  291. artist = $row.find('[data-testid="nowplaying-artist"]')
  292. }
  293. if (artist.length === 0) {
  294. if ($row.find('.second-line').length !== 0) {
  295. artist = $row.find('.second-line') // in playlist
  296. }
  297. if ($row.parents('.now-playing').length !== 0) {
  298. // Now playing bar
  299. $row = $($row.parents('.now-playing')[0])
  300. if ($row.find('.ellipsis-one-line a[href*="/artist/"]').length !== 0) {
  301. artist = $row.find('.ellipsis-one-line a[href*="/artist/"]')
  302. title = $row.find('a[data-testid="nowplaying-track-link"]')
  303. }
  304. }
  305. if ($row.parents('.Root footer').length !== 0) {
  306. // New: Now playing bar 2021-09
  307. $row = $($row.parents('.Root footer')[0])
  308. if ($row.find('.ellipsis-one-line a[href*="/artist/"],.standalone-ellipsis-one-line a[href*="/artist/"]').length !== 0) {
  309. artist = $row.find('.ellipsis-one-line a[href*="/artist/"],.standalone-ellipsis-one-line a[href*="/artist/"]')
  310. title = $row.find('.ellipsis-one-line a[href*="/album/"],.ellipsis-one-line a[href*="/track/"],.standalone-ellipsis-one-line a[href*="/album/"],.standalone-ellipsis-one-line a[href*="/track/"]')
  311. } else if ($row.find('[data-testid="context-item-info-artist"]').length !== 0) {
  312. artist = $row.find('a[data-testid="context-item-info-artist"][href*="/artist/"],[data-testid="context-item-info-artist"] a[href*="/artist/"]')
  313. title = $row.find('[data-testid="context-item-info-title"] a[href*="/album/"],[data-testid="context-item-info-title"] a[href*="/track/"]')
  314. } else if ($row.find('a[href*="/artist/"],a[href*="/album/"],a[href*="/track/"]').length > 1) {
  315. artist = $row.find('a[href*="/artist/"]')
  316. title = $row.find('a[href*="/album/"],a[href*="/track/"]')
  317. }
  318. }
  319.  
  320. const artistGridCell = $row.find('*[role="gridcell"] a[href*="/artist/"]')
  321. if (artistGridCell.length > 0) {
  322. // New playlist design
  323. artist = artistGridCell.parent()
  324. if (title.length === 0) {
  325. title = $(artistGridCell.parent().parent().find('span')[0])
  326. }
  327. if (artist.has(title)) {
  328. // title is child of artist, so it's the same node, the real title is somewhere else
  329. // This happens on album page
  330. if (artist.parent().parent().find('div.standalone-ellipsis-one-line').length) {
  331. title = $(artist.parent().parent().find('div.standalone-ellipsis-one-line')[0])
  332. }
  333. }
  334. }
  335.  
  336. const artistContent = $('.content.artist>div h1')
  337. if (artistContent.length > 0) {
  338. // Artist page
  339. artist = artistContent[0].textContent
  340. }
  341. const artistPageHeader = $('main>section[data-testid="artist-page"] [data-testid="adaptiveEntityTitle"]')
  342. if (artistPageHeader.length > 0) {
  343. // Artist page
  344. artist = artistPageHeader[0].textContent
  345. }
  346. }
  347.  
  348. if (artist.length === 0 && document.location.pathname.startsWith('/track/')) {
  349. // Single track page
  350. artist = $('section [data-testid="creator-link"][href*="/artist/"]')
  351. }
  352.  
  353. if (title && artist) {
  354. const titleText = getSongTitle(title)
  355. const artistText = getArtistName(artist)
  356.  
  357. if (titleText && artistText) {
  358. return artistText + ' - ' + titleText
  359. }
  360. }
  361.  
  362. return null
  363. }
  364.  
  365. const populateContextMenu = function (ev) {
  366. console.debug('populateContextMenu')
  367. const $this = $(this)
  368.  
  369. let prettyString = null
  370.  
  371. if (document.querySelectorAll('.main-view-container [role="row"][aria-selected="true"]').length > 1) {
  372. // Multiple tracks selected
  373. $('.main-view-container [role="row"][aria-selected="true"]').each(function () {
  374. const songInfo = findSongInfo($(this))
  375. if (songInfo) {
  376. if (prettyString) {
  377. prettyString += '\n'
  378. } else {
  379. prettyString = ''
  380. }
  381. prettyString += songInfo
  382. }
  383. })
  384. } else {
  385. // Single track
  386. prettyString = findSongInfo($this)
  387. }
  388.  
  389. let menu = $('.react-contextmenu--visible')
  390. if (!menu[0]) {
  391. menu = $('#context-menu-root')
  392. }
  393. if (!menu[0]) {
  394. menu = $('#context-menu')
  395. }
  396.  
  397. if (prettyString && menu[0]) {
  398. // Create context menu entry
  399. let entry = menu.find('.gmcopytrackinfo')
  400. if (entry.length === 0 || !entry[0]) {
  401. const liButton = menu.find('li button')
  402. let li = $(liButton[0]).parent()
  403. if (liButton.length > 4) {
  404. li = $(liButton[4]).parent()
  405. }
  406. entry = $(`
  407. <li role="presentation">
  408. <button role="menuitem" tabindex="-1">
  409. <div style="filter: grayscale(100%);font-size: 1.2rem;padding: 0px;margin: 0px 0px 0px -0.5rem;">🍝</div>
  410. <span as="span" dir="auto" style="flex:1">${menuString}</span>
  411. </button>
  412. </li>`)
  413. .appendTo(li.parent())
  414. .click(function (ev) {
  415. // Copy string to clipboard
  416. const s = entry.data('gmcopy')
  417. if (typeof GM_setClipboard !== 'undefined') { // eslint-disable-line camelcase
  418. GM_setClipboard(s)
  419. } else if (GM.setClipboard) {
  420. GM.setClipboard(s)
  421. } else {
  422. navigator.clipboard.writeText(s)
  423. }
  424. menu.parent().remove()
  425. let infoString = s
  426. if (s.split('\n').length > 2) {
  427. infoString = s.split('\n').slice(0, 2).join('\n') + '\n...'
  428. }
  429. showInfo(copiedString.replace('%s', infoString))
  430. })
  431. .mouseenter(function () {
  432. // Find the buttons that are currently not expanded
  433. const buttons = Array.from(document.querySelectorAll('#context-menu button[role=menuitem]:not([aria-expanded])'))
  434. // Remove the ones that are inside a submenu
  435. const buttonsFirstLevel = buttons.filter(button => $(button).parents('ul').length === 1)
  436. if (buttonsFirstLevel.length > 0) {
  437. // Fire mouseover event on the first button to close the other submenus
  438. buttonsFirstLevel[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
  439. }
  440. })
  441. // Copy classes from an existing entry
  442. entry.attr('class', li.attr('class'))
  443. entry.addClass('gmcopytrackinfo')
  444. entry.find('button').attr('class', li.find('button').attr('class'))
  445. entry.find('button span').attr('class', li.find('button span').attr('class'))
  446. }
  447. entry.data('gmcopy', prettyString)
  448. menu.css('margin-top', '-26px')
  449. }
  450. }
  451.  
  452. const onContextMenu = function (ev) {
  453. // Wait for the React context menu to open
  454. const t = this
  455. window.setTimeout(function () {
  456. populateContextMenu.call(t, ev)
  457. }, 200)
  458. }
  459.  
  460. let lastNode = null
  461. const searchForOpenContextMenu = function () {
  462. const node = document.querySelector('[data-context-menu-open]')
  463. if (node && node !== lastNode) {
  464. lastNode = node
  465. populateContextMenu.call(node, null)
  466. }
  467. }
  468.  
  469. const bindEvents = function () {
  470. // Remove all events and then reattach them
  471. $('*[data-testid="tracklist-row"],.now-playing,*[data-testid="now-playing-widget"]').unbind('.gmcopytrackinfo').bind('contextmenu.gmcopytrackinfo', onContextMenu)
  472. }
  473.  
  474. window.setTimeout(bindEvents, 500)
  475.  
  476. window.setInterval(bindEvents, 1000)
  477.  
  478. let searchIv = window.setInterval(searchForOpenContextMenu, 50)
  479.  
  480. document.addEventListener('visibilitychange', function () {
  481. clearInterval(searchIv)
  482. if (!document.hidden) {
  483. searchIv = window.setInterval(searchForOpenContextMenu, 50)
  484. }
  485. })
  486. document.addEventListener('focus', function () {
  487. clearInterval(searchIv)
  488. if (!document.hidden) {
  489. searchIv = window.setInterval(searchForOpenContextMenu, 50)
  490. }
  491. })
  492. })()