ListenBrainz: Extended Controls

Allows customizing which actions are shown in listen controls cards, moving "Open in Music Service" links to the main controls area, displaying source info, and auto-copying text in the "Link Listen" modal.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         ListenBrainz: Extended Controls
// @namespace    https://musicbrainz.org/user/chaban
// @version      1.1.1
// @tag          ai-created
// @description  Allows customizing which actions are shown in listen controls cards, moving "Open in Music Service" links to the main controls area, displaying source info, and auto-copying text in the "Link Listen" modal.
// @author       chaban
// @match        https://*.listenbrainz.org/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    const REGISTRY = {
        excludedFromDiscovery: ['open in musicbrainz', 'inspect listen', 'more actions', 'love', 'hate'],
        serviceMappingTable: [
            { domain: 'spotify.com', name: 'Spotify' },
            { domain: 'bandcamp.com', name: 'Bandcamp' },
            { domain: 'youtube.com', name: 'YouTube' },
            { domain: 'music.youtube.com', name: 'YouTube Music' },
            { domain: 'deezer.com', name: 'Deezer' },
            { domain: 'tidal.com', name: 'TIDAL' },
            { domain: 'music.apple.com', name: 'Apple Music' },
            { domain: 'archive.org', name: 'Internet Archive' },
            { domain: 'soundcloud.com', name: 'Soundcloud' },
            { domain: 'jamendo.com', name: 'Jamendo Music' },
            { domain: 'play.google.com', name: 'Google Play Music' }
        ],
        icons: {
            player: 'M512 256A256 256 0 1 1 0 256a256 256 0 1 1 512 0zM188.3 147.1c-7.6 4.2-12.3 12.3-12.3 20.9V344c0 8.7 4.7 16.7 12.3 20.9s16.8 4.1 24.3-.5l144-88c7.1-4.4 11.5-12.1 11.5-20.5s-4.4-16.1-11.5-20.5l-144-88c-7.4-4.5-16.7-4.7-24.3-.5z'
        }
    };

    const DEFAULT_SETTINGS = {
        moveServiceLinks: false,
        showPlayerIndicator: false,
        showLoveHate: true,
        autoCopyModalText: true,
        enabledActions: ['Link with MusicBrainz']
    };

    let settings = GM_getValue('UserJS.ListenBrainz.ExtendedListenControls', DEFAULT_SETTINGS);
    const processedCards = new WeakSet();
    const processedCopyButtons = new WeakSet();
    let discoveredActions = new Set();
    let reactFiberKey = null;

    const notify = () => window.dispatchEvent(new CustomEvent('UserJS.ListenBrainz.ExtendedListenControls.settings_changed'));

    // --- Native UI Styles ---
    GM_addStyle(`
        #lb-ext-settings-menu {
            position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
            z-index: 10000; width: 340px; max-height: 85vh; overflow-y: auto;
        }
        .lb-setting-item { display: flex; align-items: center; margin-bottom: 10px; cursor: pointer; font-size: 14px; }
        .lb-setting-item input { margin-right: 12px; }
        #lb-settings-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 9999; cursor: pointer; }
        #lb-ext-gear { cursor: pointer; }
    `);

    // --- DOM Helper ---
    function el(tag, props = {}, children = []) {
        const element = (tag === 'svg' || tag === 'path')
            ? document.createElementNS('http://www.w3.org/2000/svg', tag)
            : document.createElement(tag);
        Object.entries(props).forEach(([key, value]) => {
            if (key === 'style' && typeof value === 'object') Object.assign(element.style, value);
            else if (key === 'on') Object.entries(value).forEach(([ev, fn]) => element.addEventListener(ev, fn));
            else if (tag === 'path' || tag === 'svg') element.setAttribute(key === 'className' ? 'class' : key, value);
            else element[key] = value;
        });
        children.forEach(child => {
            if (typeof child === 'string') element.appendChild(document.createTextNode(child));
            else if (child) element.appendChild(child);
        });
        return element;
    }

    function getFriendlyServiceName(info) {
        const val = (info.music_service || info.listening_from || "").toLowerCase();
        const match = REGISTRY.serviceMappingTable.find(item => val.includes(item.domain.toLowerCase()));
        return match ? match.name : (info.music_service_name || (info.listening_from !== info.media_player ? info.listening_from : null));
    }

    // --- UI Logic ---
    function closeSettings() {
        document.getElementById('lb-ext-settings-menu')?.remove();
        document.getElementById('lb-settings-overlay')?.remove();
        document.removeEventListener('keydown', handleEsc);
    }
    function handleEsc(e) { if (e.key === 'Escape') closeSettings(); }

    function createSettingsUI() {
        if (document.getElementById('lb-ext-settings-menu')) return;
        discoverActionsOnPage();
        const createCheckbox = (label, isKey, target) => {
            const input = el('input', {
                type: 'checkbox',
                checked: isKey ? settings[target] : settings.enabledActions.includes(target),
                on: { change: (e) => {
                    if (isKey) settings[target] = e.target.checked;
                    else {
                        if (e.target.checked) !settings.enabledActions.includes(target) && settings.enabledActions.push(target);
                        else settings.enabledActions = settings.enabledActions.filter(a => a !== target);
                    }
                    GM_setValue('UserJS.ListenBrainz.ExtendedListenControls', settings);
                    notify();
                }}
            });
            return el('label', { className: 'lb-setting-item' }, [input, label]);
        };

        const menu = el('div', { id: 'lb-ext-settings-menu', className: 'card p-4 shadow' }, [
            el('h4', { className: 'card-title border-bottom pb-2 mb-3' }, ['Extended Controls']),
            createCheckbox("Show Love/Hate Buttons", true, 'showLoveHate'),
            createCheckbox("Show 'Open in Service' Links", true, 'moveServiceLinks'),
            createCheckbox("Show Source Info", true, 'showPlayerIndicator'),
            createCheckbox("Auto-copy Text in Link Listen Modal", true, 'autoCopyModalText'),
            el('div', { className: 'mt-3 mb-2 small text-muted font-weight-bold text-uppercase' }, ['Quick Buttons']),
            ...Array.from(discoveredActions).sort().map(label => createCheckbox(label, false, label)),
            el('button', { className: 'btn btn-secondary btn-block mt-3', on: { click: closeSettings } }, ['Close'])
        ]);

        document.body.appendChild(el('div', { id: 'lb-settings-overlay', on: { click: closeSettings } }));
        document.body.appendChild(menu);
        document.addEventListener('keydown', handleEsc);
    }

    function discoverActionsOnPage() {
        document.querySelectorAll('.listen-card .dropdown-item').forEach(item => {
            const label = (item.title || item.getAttribute('aria-label') || item.innerText || "").trim();
            const lower = label.toLowerCase();
            if (lower.startsWith('open in ') || REGISTRY.excludedFromDiscovery.includes(lower) || !label) return;
            discoveredActions.add(label);
        });
    }

    function injectSettingsButton() {
        if (document.getElementById('lb-ext-gear')) return;
        const navBottom = document.querySelector('.navbar-bottom');
        if (!navBottom) return;
        navBottom.appendChild(el('a', { id: 'lb-ext-gear', on: { click: (e) => { e.preventDefault(); createSettingsUI(); } } }, ['Extended Controls']));
    }

    // --- Core Logic ---
    function getListenData(domElement) {
        if (!reactFiberKey) reactFiberKey = Object.keys(domElement).find(k => k.startsWith("__reactFiber"));
        const fiber = domElement[reactFiberKey];
        return fiber?.return?.memoizedProps?.listen || fiber?.return?.return?.memoizedProps?.listen || null;
    }

    function createQuickBtn(originalItem, customClass, isLink) {
        const icon = originalItem.querySelector('svg, i, img');
        const children = icon ? [icon.cloneNode(true)] : [(originalItem.title || "X").substring(0, 1)];
        return el(isLink ? 'a' : 'button', {
            className: `btn btn-transparent lb-ext-btn ${customClass}`,
            title: originalItem.title || originalItem.getAttribute('aria-label') || "",
            href: isLink ? originalItem.href : undefined,
            target: isLink ? '_blank' : undefined,
            rel: isLink ? 'noopener noreferrer' : undefined,
            on: isLink ? {} : { click: (e) => { e.stopPropagation(); e.preventDefault(); originalItem.click(); } }
        }, children);
    }

    function addQuickButtons(card) {
        if (processedCards.has(card)) return;
        const controls = card.querySelector('.listen-controls');
        const menuBtn = controls?.querySelector('.dropdown-toggle');
        if (!controls || !menuBtn) return;

        // Native elements to potentially hide
        const nativeLove = controls.querySelector('.love');
        const nativeHate = controls.querySelector('.hate');

        const stateRegistry = { staticMoved: [], originals: [] };

        // 1. Static Actions
        card.querySelectorAll('.dropdown-item').forEach(item => {
            const label = (item.title || item.getAttribute('aria-label') || "").trim();
            const lowerLabel = label.toLowerCase();
            if (!lowerLabel.startsWith('open in ') && !REGISTRY.excludedFromDiscovery.includes(lowerLabel)) {
                const moved = createQuickBtn(item, 'lb-static-moved', false);
                controls.insertBefore(moved, menuBtn);
                stateRegistry.staticMoved.push({ el: moved, name: label });
                stateRegistry.originals.push({ el: item, name: label });
            }
        });

        // 2. Service Link Slot
        let cardServiceOriginal = null, movedServiceBtn = null;
        card.querySelectorAll('.dropdown-item').forEach(item => {
            const title = (item.title || item.getAttribute('aria-label') || "").toLowerCase();
            if (title.startsWith('open in ') && !REGISTRY.excludedFromDiscovery.includes(title)) {
                cardServiceOriginal = item;
                movedServiceBtn = createQuickBtn(item, 'lb-dynamic-moved', true);
                controls.insertBefore(movedServiceBtn, menuBtn);
            }
        });

        // 3. Player Indicator Slot
        let indicator = null;
        const info = getListenData(card)?.track_metadata?.additional_info;
        if (info) {
            const player = info.media_player;
            const client = info.submission_client;
            const serviceFriendly = getFriendlyServiceName(info);
            const tooltipLines = [];
            if (player) tooltipLines.push(`Player: ${player}`);
            if (client && client !== player) tooltipLines.push(`Client: ${client}`);
            if (serviceFriendly) tooltipLines.push(`Service: ${serviceFriendly}`);

            if (tooltipLines.length > 0) {
                indicator = el('button', {
                    className: 'btn btn-transparent lb-player-indicator',
                    style: { cursor: 'help' },
                    title: tooltipLines.join('\n'),
                    on: { click: (e) => e.stopPropagation() }
                }, [
                    el('svg', { viewBox: '0 0 512 512', style: { width: '1em', height: '1em', fill: 'currentColor', verticalAlign: '-0.125em' } }, [
                        el('path', { d: REGISTRY.icons.player })
                    ])
                ]);
                controls.insertBefore(indicator, menuBtn);
            }
        }

        const update = () => {
            // Native button visibility
            if (nativeLove) nativeLove.style.display = settings.showLoveHate ? '' : 'none';
            if (nativeHate) nativeHate.style.display = settings.showLoveHate ? '' : 'none';

            // Custom controls visibility
            stateRegistry.staticMoved.forEach(m => m.el.style.display = settings.enabledActions.includes(m.name) ? '' : 'none');
            stateRegistry.originals.forEach(o => o.el.style.display = settings.enabledActions.includes(o.name) ? 'none' : 'block');

            const canMoveService = settings.moveServiceLinks && movedServiceBtn;
            if (movedServiceBtn) movedServiceBtn.style.display = canMoveService ? '' : 'none';
            if (cardServiceOriginal) cardServiceOriginal.style.display = canMoveService ? 'none' : 'block';
            if (indicator) indicator.style.display = (settings.showPlayerIndicator && !canMoveService) ? '' : 'none';
        };

        window.addEventListener('UserJS.ListenBrainz.ExtendedListenControls.settings_changed', update);
        update();
        processedCards.add(card);
    }

    function scanPage() {
        injectSettingsButton();

        if (!window.location.pathname.includes('/settings/link-listens')) {
            discoverActionsOnPage();
            document.querySelectorAll('.listen-card').forEach(addQuickButtons);
        }

        if (settings.autoCopyModalText) {
            const modal = document.getElementById('MBIDMappingModal');
            if (modal) modal.querySelectorAll('button').forEach(btn => {
                if (!processedCopyButtons.has(btn) && btn.innerText.toLowerCase().includes('copy text')) {
                    processedCopyButtons.add(btn);
                    btn.click();
                }
            });
        }
    }

    scanPage();
    new MutationObserver(scanPage).observe(document.body, { childList: true, subtree: true });
})();