Chosic Copy Button

Add copy button on chosic genre finder page

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Chosic Copy Button
// @namespace    https://greasyfork.org/users/1470715
// @author       cattishly6060
// @version      1.4
// @description  Add copy button on chosic genre finder page
// @match        https://www.chosic.com/music-genre-finder/*
// @match        https://www.chosic.com/artist/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chosic.com
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  /**
   * Main function
   */

  /**
   * @param {string} textToCopy
   * @param {string} btnText
   * @returns {HTMLAnchorElement}
   */
  function createCopyBtn(textToCopy = "", btnText = "Copy") {
    // Create the button/link element
    const copyButton = document.createElement('a');
    copyButton.textContent = btnText;
    copyButton.href = '#'; // Makes it look like a link
    copyButton.style.cssText = `
      display: inline-block;
      margin: 5px;
      padding: 2px 5px;
      background: #0078d7;
      color: white;
      border-radius: 2px;
      text-decoration: none;
      cursor: pointer;
      font-size: 12px;
      font-weight: bold;
    `;

    // Add click event handler
    copyButton.addEventListener('click', function(e) {
      e.preventDefault(); // Prevent default anchor behavior

      // Copy text to clipboard
      navigator.clipboard.writeText(textToCopy)
        .then(() => {
          // Visual feedback
          const originalText = copyButton.textContent;
          copyButton.textContent = 'Copied!';
          setTimeout(() => {
            copyButton.textContent = originalText;
          }, 2000);
        })
        .catch(err => {
          console.error('Failed to copy text: ', err);
        });
    });

    return copyButton;
  }

  /**
   * @param {string} uri
   * @param {string} btnText
   * @returns {HTMLAnchorElement}
   */
  function createOpenBtn(uri = "", btnText = "Open") {
    // Create the button/link element
    const openButton = document.createElement('a');
    openButton.textContent = btnText;
    openButton.href = uri;
    openButton.target = '_blank';
    openButton.style.cssText = `
      display: inline-block;
      margin: 5px;
      padding: 2px 5px;
      background: #0078d7;
      color: white;
      border-radius: 2px;
      text-decoration: none;
      cursor: pointer;
      font-size: 12px;
      font-weight: bold;
    `;
    return openButton;
  }

  /**
   * @param {string} textToCopy
   * @param {string} uri
   * @param {string} btnText
   * @returns {HTMLDivElement}
   */
  function createGroupBtn(textToCopy = "", uri = "", btnText = "Open") {
    const div = document.createElement('div');
    div.style.display = 'inline-block';

    // Create the button/link element
    const openButton = document.createElement('a');
    openButton.textContent = btnText;
    openButton.href = uri;
    openButton.target = '_blank';
    openButton.style.cssText = `
      display: inline-block;
      margin: 5px 0 5px 5px;
      padding: 2px 5px;
      background: #0078d7;
      color: white;
      border-radius: 2px 0 0 2px;
      text-decoration: none;
      cursor: pointer;
      font-size: 12px;
      font-weight: bold;
    `;

    // Create copy button
    const copyButton = document.createElement('a');
    copyButton.href = '#';
    copyButton.style.cssText = `
      display: inline-block;
      margin: 5px 5px 5px 0;
      padding: 2px 10px;
      color: white;
      border-radius: 0 2px 2px 0;
      text-decoration: none;
      cursor: pointer;
      font-size: 12px;
      font-weight: bold;
    `;
    copyButton.style.background = '#005ca3';

    // SVG style change:
    copyButton.innerHTML = `<svg width="12" height="12" viewBox="0 0 448 512" fill="currentColor" style="display: inline-block; vertical-align: middle;"><path d="M208 0L332.1 0c12.7 0 24.9 5.1 33.9 14.1l67.9 67.9c9 9 14.1 21.2 14.1 33.9L448 336c0 26.5-21.5 48-48 48l-192 0c-26.5 0-48-21.5-48-48l0-288c0-26.5 21.5-48 48-48zM48 128l80 0 0 64-64 0 0 256 192 0 0-32 64 0 0 48c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 176c0-26.5 21.5-48 48-48z"/></svg>`;

    // Add click event handler
    copyButton.addEventListener('click', function(e) {
      e.preventDefault(); // Prevent default anchor behavior

      // Copy text to clipboard
      navigator.clipboard.writeText(textToCopy)
        .then(() => {
          // Visual feedback - change both icon and text
          const originalHTML = copyButton.innerHTML;
          copyButton.innerHTML = `<svg width="12" height="12" viewBox="0 0 448 512" fill="currentColor" style="display: inline-block; vertical-align: middle;"><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>`;
          setTimeout(() => {
            copyButton.innerHTML = originalHTML;
          }, 2000);
        })
        .catch(err => {
          console.error('Failed to copy text: ', err);
        });
    });

    div.append(openButton, copyButton);
    return div;
  }

  /**
   * Variables
   */

  /** @type {HTMLElement[]} */
  let toRemoveElement = [];

  /**
   * Main Handler (Listener/Observer)
   */
  function main() {

    if (toRemoveElement?.length) {
      for (const e of toRemoveElement) {
        e?.remove();
      }
      toRemoveElement = [];
    }

    /** @type {?MutationObserver} */
    const observerSp = waitForElement('#spotify-tags .pl-tags > a', (_) => {

      /** @type {HTMLElement[]} */
      const spGenreList = [...document.querySelectorAll("#spotify-tags .pl-tags > a")];

      /** @type {string[]} */
      const spotifyGenres = spGenreList.map(e => e.innerText.trim().toLowerCase());

      /** @type {{uri: string, genre: string}[]} */
      const spotifyTags = spGenreList.map(e => ({uri: e.href, genre: e.innerText.trim().toLowerCase()}));

      // remove native genre tags
      spGenreList.forEach(e => e.remove())
      console.log({spotifyGenres, spotifyTags}); // todo log

      if (spotifyGenres?.length) {
        const copyBtn = createCopyBtn(spotifyGenres.join(', '), "Copy");
        document.querySelector("#spotify-tags .section-header")?.appendChild(copyBtn);
      }

      if (spotifyTags?.length) {
        const div = document.createElement('div');
        div.style.cssText = `text-align: center;`;
        for (const spTag of spotifyTags) {
          const groupBtn = createGroupBtn(spTag.genre, spTag.uri, spTag.genre);
          div.appendChild(groupBtn);
        }
        const isRelatedGenre = Boolean(document.querySelector("#spotify-tags span.related-artists-genres-extra"));
        if (isRelatedGenre) {
          document.querySelector("#spotify-tags div.pl-tags")
            ?.insertAdjacentElement('afterend', div);
        } else {
          document.querySelector("#spotify-tags .section-header")
            ?.insertAdjacentElement('afterend', div);
        }
      }
    }, {timeoutDelay: 5_000});

    /** @type {?MutationObserver} */
    const observerWk = waitForElement('#wiki-genres.pl-tags > a', (_) => {

      /** @type {HTMLElement[]} */
      const wikiGenreList = [...document.querySelectorAll("#wiki-genres.pl-tags > a")];

      /** @type {string[]} */
      const wikiGenres = wikiGenreList.map(e => e.innerText.trim().toLowerCase());

      /** @type {{uri: string, genre: string}[]} */
      const wikiTags = wikiGenreList.map(e => ({uri: e.href, genre: e.innerText.trim().toLowerCase()}));

      // remove native wiki genre list
      wikiGenreList.forEach(e => e.remove());
      console.log({wikiGenres, wikiTags}); // todo log

      if (wikiGenres?.length) {
        const copyBtn = createCopyBtn(wikiGenres.join(', '), "Copy");
        toRemoveElement.push(copyBtn);
        document.querySelector("#wiki-genres.pl-tags")
          ?.parentNode
          ?.querySelector(".section-header")
          ?.appendChild(copyBtn);
      }

      if (wikiTags?.length) {
        const div = document.createElement('div');
        div.style.cssText = `text-align: center;`;
        for (const spTag of wikiTags) {
          const groupBtn = createGroupBtn(spTag.genre, spTag.uri, spTag.genre);
          div.appendChild(groupBtn);
        }
        toRemoveElement.push(div);
        document.querySelector("#wiki-genres.pl-tags")
          ?.parentNode
          ?.querySelector(".section-header")
          ?.insertAdjacentElement('afterend', div);
      }
    }, {timeoutDelay: 5_000});

    // player copy button
    const observerTrackItem = waitForElement("#song-player div.track-list-item", (_) => {
      const trackItem = document.querySelector("#song-player div.track-list-item");
      if (trackItem) {
        const titleAnchor = trackItem.querySelector(".track-list-item-info-text a");
        const authorAnchor = trackItem.querySelector(".track-list-item-info-genres a");

        if (titleAnchor && authorAnchor) {
          /** @type {?string} */
          const title = titleAnchor.innerText;
          /** @type {?string} */
          const author = authorAnchor.innerText;

          /** @type {?string} */
          const cArtistUri = authorAnchor.getAttribute("data-link");
          /** @type {?string} */
          const cListGeneUri = titleAnchor.getAttribute("data-link");
          /** @type {?string} */
          const spArtistId = cArtistUri?.split('/')?.[5];
          /** @type {?string} */
          const spTrackId = cListGeneUri?.split('track=')?.[1];

          /** @type {string} */
          const spArtistUri = `https://open.spotify.com/artist/${spArtistId}`;
          /** @type {string} */
          const spTrackUri = `https://open.spotify.com/track/${spTrackId}`;

          const div = document.createElement("div");
          div.style.cssText = "position: relative; width: 100%;";
          trackItem.insertBefore(div, trackItem.firstChild);

          /** @type {HTMLDivElement[]} */
          const divs = [];

          if (title && author) {
            const _div = document.createElement("div");
            _div.style.cssText = "position: absolute; right: 0; top: 0;";
            const copyTitle = createCopyBtn(title, "Copy Title");
            const copyAuthor = createCopyBtn(author, "Copy Author");
            const copyAuthorTitle = createCopyBtn(`${title} - ${author}`, "Copy Title + Author");
            _div.append(copyTitle, copyAuthor, copyAuthorTitle);
            divs.push(_div);
          }

          if (spTrackId && spArtistId) {
            const _div = document.createElement("div");
            _div.style.cssText = "position: absolute; right: 0; top: 40px;";
            const copyTrackId = createCopyBtn(spTrackId, "Copy Track ID");
            const copyArtistId = createCopyBtn(spArtistId, "Copy Artist ID");
            _div.append(copyArtistId, copyTrackId);
            divs.push(_div);

            const __div = document.createElement("div");
            __div.style.cssText = "position: absolute; right: 0; top: 80px;";
            const groupArtist = createGroupBtn(spArtistUri, spArtistUri, "Open Artist");
            const groupTrack = createGroupBtn(spTrackUri, spTrackUri, "Open Track");
            __div.append(groupArtist, groupTrack);
            divs.push(__div);
          }

          div.append(...divs);

          console.log({
            title,
            author,
            cArtistUri,
            cListGeneUri,
            spArtistId,
            spTrackId,
            spArtistUri,
            spTrackUri,
          }, {depth: null, colors: true}); // todo log
        }
      }
    }, {timeoutDelay: 5_000});

    if (window.location.pathname.startsWith('/artist/')) {

      /** @type {HTMLAnchorElement[]} */
      const genreElements = [...(document.querySelectorAll("#artist-genres a") || [])];

      /** @type {string[]} */
      const genres = genreElements
        .map(e => e.innerText.trim().toLowerCase());

      genreElements.forEach(e => e.remove());

      if (genres?.length) {
        const div = document.createElement('div');
        for (const g of genres) {
          const genreUri = `https://www.chosic.com/genre-chart/${toSlug(g)}`;
          const groupBtn = createGroupBtn(g, genreUri, g);
          div.appendChild(groupBtn);
        }
        document.querySelector("#artist-genres")
          ?.insertAdjacentElement('afterend', div);

        const copyBtn = createCopyBtn(genres.join(', '), "Copy Genres");
        document.querySelector("#artist-genres")
          ?.appendChild(copyBtn);
      }
    }
  }

  // initiate listener for first page load
  main();

  // listen on data changed
  const loading = document.querySelector("#primary .loading-result");
  if (loading) {
    const styleObserver = observeInlineStyleChanges(loading, (oldStyle, newStyle) => {
      console.log('|| Inline styles changed:');
      console.log('|| Old style:', oldStyle);
      console.log('|| New style:', newStyle);
      if (newStyle?.includes("display: none;")) {
        console.log('|| Starting main..');
        main();
      }
    });
  }

  /**
   * **********************
   * Utility
   * **********************
   */

  /**
   * @param {string} selector
   * @param {function(HTMLElement): void} callback
   * @param {{rootElement: ?HTMLElement, timeoutDelay: ?number}} [options={}]
   * @returns {?MutationObserver}
   */
  function waitForElement(selector, callback, options = {}) {
    // Default to document.body if no root element is specified
    const rootElement = options.rootElement || document.body;
    delete options.rootElement; // Remove our custom option before passing to MutationObserver
    /** @type {?number} */
    const timeoutDelay = options?.timeoutDelay;
    delete options?.timeoutDelay;

    // First try immediately (element might already exist)
    const element = document.querySelector(selector);
    if (element) {
      callback(element);
      return;
    }

    /** @type {?Timeout} */
    let removeObserverTimeout;

    const observer = new MutationObserver((mutations, obs) => {
      // Only query when nodes are added
      for (const mutation of mutations) {
        if (mutation.addedNodes.length) {
          const el = document.querySelector(selector);
          if (el) {
            clearTimeout(removeObserverTimeout);
            obs.disconnect();
            callback(el);
            return;
          }
        }
      }
    });

    if (timeoutDelay) {
      removeObserverTimeout = setTimeout(() => {
        observer.disconnect();
      }, timeoutDelay);
    }

    observer.observe(rootElement, {
      childList: true,
      subtree: true,
      ...options
    });

    // Return the observer so caller can disconnect if needed
    return observer;
  }

  /**
   * Observes inline style changes on a specific DOM element
   * @param {HTMLElement} targetElement - The element to observe
   * @param {Function} callback - Function to call when inline styles change
   * @returns {?MutationObserver} The observer instance
   */
  function observeInlineStyleChanges(targetElement, callback) {
    if (!targetElement) {
      return;
    }
    // Options for the observer (what to observe)
    const config = {
      attributes: true, // Watch for attribute changes
      attributeFilter: ['style'], // Only watch the style attribute
      attributeOldValue: true, // Record the old value before mutation
    };

    // Create an observer instance
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
          // Get the old and new style values
          const oldValue = mutation.oldValue;
          const newValue = targetElement.getAttribute('style');

          // Execute callback with both values
          callback(oldValue, newValue);
        }
      });
    });

    // Start observing the target element
    observer.observe(targetElement, config);
    return observer;
  }

  /**
   * @param {string} str
   * @returns {string}
   */
  function toSlug(str) {
    return str
      .toLowerCase()
      .replace(/\s+/g, '-')
      .replace(/[^\w\-]+/g, '')
      .replace(/\-\-+/g, '-')
      .replace(/^-+/, '')
      .replace(/-+$/, '');
  }

})();