exyezed / Spotify Enhancer (Copy Track Info, ID & Link)

// ==UserScript==
// @name         Spotify Enhancer (Copy Track Info, ID & Link)
// @description  Integrates copy button with options in Spotify Web Player for easy access to track information, IDs, and links.
// @icon         https://raw.githubusercontent.com/exyezed/spotify-enhancer/refs/heads/main/extras/spotify-enhancer.png
// @version      1.2
// @author       exyezed
// @namespace    https://github.com/exyezed/spotify-enhancer/
// @supportURL   https://github.com/exyezed/spotify-enhancer/issues
// @license      MIT
// @match        https://open.spotify.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    const createSVG = (path, viewBox = "0 0 384 512", width = "16", height = "16", style = "cursor: pointer; margin-left: 8px; fill: #b3b3b3; vertical-align: middle") => {
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        svg.setAttribute("viewBox", viewBox);
        svg.setAttribute("width", width);
        svg.setAttribute("height", height);
        svg.setAttribute("style", style);
        const pathElement = document.createElementNS("http://www.w3.org/2000/svg", "path");
        pathElement.setAttribute("d", path);
        svg.appendChild(pathElement);
        return svg;
    };

    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");
    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");
    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");
    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");
    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");
    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");
    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");
    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");
    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");

    const defaultSettings = {
        copyType: 'trackId',
        isEnabled: true
    };

    let settings = GM_getValue('spotifyCopySettings', defaultSettings);

    function removeCopyButtons() {
        const copyButtons = document.querySelectorAll('.copy-track-info-btn');
        copyButtons.forEach(button => button.remove());
    }

    function createMenuSeparator() {
        const separator = document.createElement('div');
        separator.style.height = '1px';
        separator.style.backgroundColor = '#404040';
        separator.style.margin = '8px 0';
        return separator;
    }

    function getTrackId(row) {
        const trackLink = row.querySelector('a[href^="/track/"]');
        if (trackLink) {
            const trackId = trackLink.getAttribute('href').split('/').pop();
            return {
                id: trackId,
                link: `https://open.spotify.com/track/${trackId}`
            };
        }
        return null;
    }

    function getTrackInfo(row) {
        let title, artist;

        if (window.location.href.startsWith("https://open.spotify.com/artist/")) {
            const titleElement = row.querySelector('div[data-testid="tracklist-row"] div[role="gridcell"]:nth-child(2)');
            const artistElement = document.querySelector('span[data-testid="entityTitle"] h1');

            title = titleElement ? titleElement.textContent.trim() : 'Title Not Found';
            artist = artistElement ? artistElement.textContent.trim() : 'Artist Not Found';
        } else {
            const titleElement = row.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line');
            const artistElement = row.querySelector('span.encore-text-body-small[data-encore-id="text"]');

            title = titleElement ? titleElement.childNodes[0].textContent.trim() : 'Title Not Found';
            const artists = artistElement ? Array.from(artistElement.querySelectorAll('a')).map(el => el.textContent.trim()) : [];
            artist = artists.length > 0 ? artists.join(', ') : 'Artist Not Found';
        }

        return {
            title,
            artist,
            titleFirst: `${title} - ${artist}`,
            artistFirst: `${artist} - ${title}`
        };
    }

    function addCopyButton() {
        if (!settings.isEnabled) return;
    
        const trackRows = document.querySelectorAll('[data-testid="tracklist-row"]');
        trackRows.forEach(row => {
            if (row.querySelector('.copy-track-info-btn')) return;
    
            const copyBtn = document.createElement('span');
            copyBtn.className = 'copy-track-info-btn';
            copyBtn.style.display = 'inline-flex';
            copyBtn.style.alignItems = 'center';
            copyBtn.appendChild(copyIcon.cloneNode(true));
    
            copyBtn.onclick = function(e) {
                e.preventDefault();
                e.stopPropagation();
    
                const trackInfo = getTrackInfo(row);
                let textToCopy;
                let isSuccess = true;
    
                switch (settings.copyType) {
                    case 'trackId':
                        const trackIdInfo = getTrackId(row);
                        if (trackIdInfo && trackIdInfo.id) {
                            textToCopy = trackIdInfo.id;
                        } else {
                            textToCopy = 'Track ID Not Found';
                            isSuccess = false;
                        }
                        break;
                    case 'trackLink':
                        const trackLinkInfo = getTrackId(row);
                        if (trackLinkInfo && trackLinkInfo.link) {
                            textToCopy = trackLinkInfo.link;
                        } else {
                            textToCopy = 'Track Link Not Found';
                            isSuccess = false;
                        }
                        break;
                    case 'titleFirst':
                        textToCopy = trackInfo.titleFirst;
                        break;
                    case 'artistFirst':
                        textToCopy = trackInfo.artistFirst;
                        break;
                    default:
                        textToCopy = 'Invalid copy type';
                        isSuccess = false;
                }
    
                navigator.clipboard.writeText(textToCopy).then(() => {
                    this.replaceChild(isSuccess ? successIcon.cloneNode(true) : errorIcon.cloneNode(true), this.firstChild);
                    setTimeout(() => {
                        this.replaceChild(copyIcon.cloneNode(true), this.firstChild);
                    }, 250);
                }).catch(() => {
                    this.replaceChild(errorIcon.cloneNode(true), this.firstChild);
                    setTimeout(() => {
                        this.replaceChild(copyIcon.cloneNode(true), this.firstChild);
                    }, 250);
                });
            };
    
            const compactContainer = row.querySelector('div[class="ft6dUifK4i03829TBAqC"]');
            const listContainer = row.querySelector('div[class="_iQpvk1c9OgRAc8KRTlH"]');
    
            if (compactContainer) {
                const explicitSpan = compactContainer.querySelector('.Ps9zgW56WZaBVLo1n3cg');
                if (explicitSpan) {
                    const parentSpan = explicitSpan.closest('span[data-encore-id="text"]');
                    parentSpan.after(copyBtn);
                } else {
                    compactContainer.appendChild(copyBtn);
                }
            } else if (listContainer) {
                const textContainer = listContainer.querySelector('[data-encore-id="text"]');
                if (textContainer) {
                    textContainer.style.display = 'flex';
                    textContainer.style.alignItems = 'center';
                    textContainer.appendChild(copyBtn);
                } else {
                    listContainer.appendChild(copyBtn);
                }
            }
        });
    }

    function createOptionsButton() {
        const actionBar = document.querySelector('.eSg4ntPU2KQLfpLGXAww[data-testid="action-bar-row"]');
        if (!actionBar || actionBar.querySelector('.spotify-copy-options')) return;

        const optionsBtn = document.createElement('button');
        optionsBtn.className = 'Button-sc-1dqy6lx-0 dbhFGF spotify-copy-options';
        optionsBtn.appendChild(copyIcon.cloneNode(true));
        const optionsText = document.createElement('span');
        optionsText.textContent = 'OPTIONS';
        optionsText.style.marginLeft = '8px';
        optionsBtn.appendChild(optionsText);

        const menu = document.createElement('div');
        menu.className = 'spotify-copy-menu';
        menu.style.display = 'none';
        menu.style.position = 'absolute';
        menu.style.backgroundColor = '#282828';
        menu.style.padding = '8px';
        menu.style.borderRadius = '4px';
        menu.style.zIndex = '1000';
        menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)';

        const toggleOption = createMenuItem(settings.isEnabled ? 'Enabled' : 'Disabled',
                                         settings.isEnabled ? enabledIcon : disabledIcon,
                                         'toggle');
        const separator = createMenuSeparator();
        const titleFirstOption = createMenuItem('Track Info (Title - Artist)', titleIcon, 'titleFirst');
        const artistFirstOption = createMenuItem('Track Info (Artist - Title)', artistIcon, 'artistFirst');
        const trackIdOption = createMenuItem('Track ID', trackIdIcon, 'trackId');
        const trackLinkOption = createMenuItem('Track Link', trackLinkIcon, 'trackLink');

        menu.appendChild(toggleOption);
        menu.appendChild(separator);
        menu.appendChild(titleFirstOption);
        menu.appendChild(artistFirstOption);
        menu.appendChild(trackIdOption);
        menu.appendChild(trackLinkOption);

        optionsBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
            const rect = optionsBtn.getBoundingClientRect();
            menu.style.top = `${rect.bottom + 5}px`;
            menu.style.left = `${rect.left}px`;
        });

        document.addEventListener('click', () => {
            menu.style.display = 'none';
        });

        const moreButton = actionBar.querySelector('[data-testid="more-button"]');
        if (moreButton) {
            moreButton.after(optionsBtn);
        }
        document.body.appendChild(menu);
    }

    function createMenuItem(text, icon, value) {
        const item = document.createElement('div');
        item.className = 'spotify-copy-menu-item';
        item.style.padding = '8px';
        item.style.cursor = 'pointer';
        item.style.display = 'flex';
        item.style.alignItems = 'center';

        if (value === 'toggle') {
            item.style.color = settings.isEnabled ? '#1ed760' : '#f3727f';
        } else {
            item.style.color = settings.copyType === value ? '#1ed760' : '#ffffff';
        }

        item.appendChild(icon.cloneNode(true));
        const textSpan = document.createElement('span');
        textSpan.textContent = text;
        item.appendChild(textSpan);

        item.addEventListener('mouseover', () => {
            item.style.backgroundColor = '#333333';
        });

        item.addEventListener('mouseout', () => {
            item.style.backgroundColor = 'transparent';
        });

        item.addEventListener('click', () => {
            if (value === 'toggle') {
                settings.isEnabled = !settings.isEnabled;
                item.style.color = settings.isEnabled ? '#1ed760' : '#f3727f';
                item.replaceChild(settings.isEnabled ? enabledIcon.cloneNode(true) : disabledIcon.cloneNode(true), item.firstChild);
                textSpan.textContent = settings.isEnabled ? 'Enabled' : 'Disabled';

                if (!settings.isEnabled) {
                    removeCopyButtons();
                } else {
                    addCopyButton();
                }
            } else {
                settings.copyType = value;
                document.querySelectorAll('.spotify-copy-menu-item').forEach(menuItem => {
                    if (!menuItem.textContent.includes('Enabled') && !menuItem.textContent.includes('Disabled')) {
                        menuItem.style.color = menuItem.textContent.includes(text) ? '#1ed760' : '#ffffff';
                    }
                });
            }
            GM_setValue('spotifyCopySettings', settings);
        });

        return item;
    }

    function initialize() {
        createOptionsButton();
        addCopyButton();
    }

    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            if (mutation.addedNodes.length) {
                initialize();
            }
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    initialize();
    console.log('Spotify Enhancer (Copy Track Info, ID & Link) is running');
})();