Spotify Dislike Button

добавляет кнопку дизлайка, горячие клавиши D/В и плавающей шестерёнки настроек с функцией пропуска лайкнутых треков

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Spotify Dislike Button
// @namespace    http://tampermonkey.net/
// @version      2.6
// @description  добавляет кнопку дизлайка, горячие клавиши D/В и плавающей шестерёнки настроек с функцией пропуска лайкнутых треков
// @author       torch
// @match        https://open.spotify.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    function utf8_to_b64(str) {
        return window.btoa(unescape(encodeURIComponent(str)));
    }

    function b64_to_utf8(str) {
        return decodeURIComponent(escape(window.atob(str)));
    }

    let configModified = false;
    let lastSkippedTrackUri = '';
    let skipCooldownUntil = 0; // Время, до которого автоматический пропуск заблокирован

    // Загрузка настроек из localStorage
    const settings = JSON.parse(localStorage.getItem('spotify_userscript_settings') || '{"skipLikedTracks": false}');

    function saveSettings() {
        localStorage.setItem('spotify_userscript_settings', JSON.stringify(settings));
    }

    // Установка кулдауна для предотвращения гонки состояний DOM
    function setSkipCooldown(ms = 1500) {
        skipCooldownUntil = Date.now() + ms;
        console.log(`[Spotify Userscript] Кулдаун автопропуска установлен на ${ms}мс.`);
    }

    // Шаг 1: Модификация флагов в remoteConfig
    function modifyRemoteConfig() {
        if (configModified) return true;
        const configScript = document.getElementById('remoteConfig');
        if (!configScript) return false;

        try {
            const rawContent = configScript.textContent.trim();
            if (!rawContent) return false;

            const config = JSON.parse(b64_to_utf8(rawContent));

            // Активируем запрашиваемые функции
            config.enableMagpie = true;
            config.enableSmartShuffle = true;
            config.enableExcludeTrackFromTasteProfile = true;

            configScript.textContent = utf8_to_b64(JSON.stringify(config));
            console.log('[Spotify Userscript] Функции успешно активированы в remoteConfig.');
            configModified = true;
            return true;
        } catch (error) {
            console.error('[Spotify Userscript] Ошибка изменения конфигурации:', error);
            return false;
        }
    }

    function closeContextMenu() {
        console.log('[Spotify Debug] Закрытие меню по нажатию Escape.');
        document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
    }

    // Симуляция клика через Pointer и Mouse события с глубоким всплытием
    function simulatePhysicalClick(target, isRightClick = false) {
        const rect = target.getBoundingClientRect();
        const buttonCode = isRightClick ? 2 : 0;
        const buttonsCode = isRightClick ? 2 : 1;
        const clientX = rect.left + rect.width / 2;
        const clientY = rect.top + rect.height / 2;

        const eventOpts = {
            bubbles: true,
            cancelable: true,
            view: window,
            button: buttonCode,
            buttons: buttonsCode,
            clientX: clientX,
            clientY: clientY,
            pointerId: 1,
            width: 1,
            height: 1,
            pressure: 0.5,
            isPrimary: true
        };

        target.dispatchEvent(new PointerEvent('pointerdown', eventOpts));
        target.dispatchEvent(new PointerEvent('pointerup', eventOpts));
        target.dispatchEvent(new MouseEvent('mousedown', eventOpts));
        target.dispatchEvent(new MouseEvent('mouseup', eventOpts));

        if (isRightClick) {
            target.dispatchEvent(new MouseEvent('contextmenu', eventOpts));
        } else {
            target.dispatchEvent(new MouseEvent('click', eventOpts));
        }
    }

    // Функция поиска самого глубокого вложенного элемента с текстом
    function getInnermostChildWithText(element, keyword) {
        if (!keyword) return element;
        const children = Array.from(element.querySelectorAll('*'));
        const matchedChildren = children.filter(child => child.textContent.toLowerCase().includes(keyword));
        if (matchedChildren.length > 0) {
            return matchedChildren[matchedChildren.length - 1];
        }
        return element;
    }

    // Шаг 2: Фоновый вызов действия "Скрыть трек" из меню
    async function handleDislike() {
        let menuOpened = false;

        let moreBtn = document.querySelector('.NowPlayingView [data-testid="more-button"]');
        if (!moreBtn) {
            moreBtn = document.querySelector('[data-testid="now-playing-widget"] [data-testid="more-button"]');
        }

        if (moreBtn) {
            moreBtn.click();
            menuOpened = true;
        } else {
            const nowPlayingWidget = document.querySelector('[data-testid="now-playing-widget"]');
            if (nowPlayingWidget) {
                const targets = [
                    nowPlayingWidget.querySelector('[data-testid="context-item-link"]'),
                    nowPlayingWidget.querySelector('[data-testid="cover-art-button"]'),
                    nowPlayingWidget.querySelector('.VeRM5WDIEoz7GcxE'),
                    nowPlayingWidget.querySelector('.KN5KA9u52qQprYjq'),
                    nowPlayingWidget
                ].filter(Boolean);

                targets.forEach((target) => {
                    simulatePhysicalClick(target, true);
                });

                menuOpened = true;
            }
        }

        if (!menuOpened) {
            console.warn('[Spotify Dislike] Не удалось открыть контекстное меню трека');
            return;
        }

        const keywords = ['скрыть', 'не рекомендовать', 'исключить', 'не воспроизводить', 'удалить из', 'hide', 'exclude', "don't play", 'block', 'remove', 'заблокировать'];

        for (let i = 0; i < 15; i++) {
            await new Promise(resolve => setTimeout(resolve, 50));

            let menuItems = [];
            const menuContainer = document.getElementById('floating-ui-popover-layer');

            if (menuContainer) {
                menuItems = Array.from(menuContainer.querySelectorAll('button, li, span, [role="menuitem"]'));
            }

            if (menuItems.length === 0) {
                const globalMenus = document.querySelectorAll('[role="menu"], [role="menuitem"], .main-contextMenu-menu, ul[class*="menu"]');
                globalMenus.forEach(menu => {
                    menuItems.push(...Array.from(menu.querySelectorAll('button, li, span, [role="menuitem"]')));
                    if (menu.tagName === 'BUTTON' || menu.tagName === 'LI' || menu.getAttribute('role') === 'menuitem') {
                        menuItems.push(menu);
                    }
                });
            }

            if (menuItems.length === 0) continue;

            let matchedKeyword = null;
            const targetTextElement = menuItems.find(el => {
                const text = el.textContent.toLowerCase();
                matchedKeyword = keywords.find(kw => text.includes(kw));
                return !!matchedKeyword;
            });

            if (targetTextElement) {
                const deepestTarget = getInnermostChildWithText(targetTextElement, matchedKeyword) || targetTextElement;
                simulatePhysicalClick(deepestTarget, false);
                return;
            }
        }

        closeContextMenu();
    }

    // Функция: Скрыть трек и переключить его дальше
    async function handleDislikeAndSkip() {
        setSkipCooldown(1500); // Блокируем ложные срабатывания автопропуска при переходе
        await handleDislike();
        await new Promise(resolve => setTimeout(resolve, 150)); // Короткая пауза для завершения работы меню

        const skipBtn = document.querySelector('[data-testid="control-button-skip-forward"]');
        if (skipBtn) {
            skipBtn.click();
        }
    }

    // Получение стабильного названия трека и исполнителей в качестве ID
    function getTrackIdentifier() {
        const nowPlayingWidget = document.querySelector('[data-testid="now-playing-widget"]');
        if (!nowPlayingWidget) return '';

        const titleEl = nowPlayingWidget.querySelector('[data-testid="context-item-info-title"]');
        const artistEl = nowPlayingWidget.querySelector('[data-testid="context-item-info-subtitles"]');

        if (titleEl) {
            return (titleEl.textContent + ' - ' + (artistEl ? artistEl.textContent : '')).trim();
        }

        const ariaLabel = nowPlayingWidget.getAttribute('aria-label');
        if (ariaLabel) return ariaLabel;

        return '';
    }

    // Автоматический пропуск лайкнутых треков
    function checkAndSkipLikedTrack() {
        if (!settings.skipLikedTracks) return;

        // Если действует кулдаун перехода, не выполняем автопропуск
        if (Date.now() < skipCooldownUntil) return;

        const nowPlayingWidget = document.querySelector('[data-testid="now-playing-widget"]');
        if (!nowPlayingWidget) return;

        // Находим кнопку лайка
        const likeBtn = nowPlayingWidget.querySelector('button[aria-checked="true"]');
        if (!likeBtn) return;

        // Получаем прогресс воспроизведения
        const progressInput = document.querySelector('[data-testid="playback-progressbar"] input');
        const progress = progressInput ? parseInt(progressInput.value, 10) : 0;

        const positionEl = document.querySelector('[data-testid="playback-position"]');

        // Считаем стартом воспроизведения первые 5 секунд
        const isStartOfTrack = (progressInput && progress < 5000) ||
                              (positionEl && ['0:00', '0:01', '0:02', '0:03', '0:04', '0:05'].includes(positionEl.textContent.trim()));

        if (!isStartOfTrack) return;

        // Получаем уникальное имя трека
        const trackUri = getTrackIdentifier();

        if (trackUri && lastSkippedTrackUri !== trackUri) {
            console.log('[Spotify Userscript] Обнаружен сохраненный (лайкнутый) трек на старте. Пропускаем...', trackUri);
            lastSkippedTrackUri = trackUri;
            setSkipCooldown(1500); // Блокируем новые автоматические действия на 1.5 сек

            const skipBtn = document.querySelector('[data-testid="control-button-skip-forward"]');
            if (skipBtn) {
                skipBtn.click();
            }
        }
    }

    // Открытие окна настроек скрипта
    function openSettingsModal() {
        if (document.getElementById('custom-settings-modal')) return;

        const modal = document.createElement('div');
        modal.id = 'custom-settings-modal';
        modal.style.position = 'fixed';
        modal.style.top = '0';
        modal.style.left = '0';
        modal.style.width = '100vw';
        modal.style.height = '100vh';
        modal.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
        modal.style.zIndex = '99999';
        modal.style.display = 'flex';
        modal.style.alignItems = 'center';
        modal.style.justifyContent = 'center';

        const modalContent = document.createElement('div');
        modalContent.style.backgroundColor = '#181818';
        modalContent.style.color = '#ffffff';
        modalContent.style.padding = '24px';
        modalContent.style.borderRadius = '8px';
        modalContent.style.width = '350px';
        modalContent.style.boxShadow = '0 4px 15px rgba(0, 0, 0, 0.6)';
        modalContent.style.fontFamily = 'sans-serif';

        modalContent.innerHTML = `
            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
                <h2 style="margin: 0; font-size: 1.25rem; font-weight: bold;">Настройки скрипта</h2>
                <button id="close-settings-modal" style="background: none; border: none; color: #b3b3b3; cursor: pointer; font-size: 1.5rem; line-height: 1;">&times;</button>
            </div>
            <div style="margin-bottom: 24px;">
                <label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
                    <input type="checkbox" id="setting-skip-liked" ${settings.skipLikedTracks ? 'checked' : ''} style="margin-right: 12px; transform: scale(1.2); accent-color: #1db954; cursor: pointer;">
                    <span style="font-weight: bold; font-size: 0.95rem;">Пропускать лайкнутые</span>
                </label>
                <p style="margin: 8px 0 0 28px; font-size: 0.8rem; color: #b3b3b3; line-height: 1.3;">Автоматически переключает трек на следующий, если он уже добавлен в Вашу медиатеку.</p>
            </div>
            <div style="display: flex; justify-content: flex-end;">
                <button id="save-settings-modal" style="background-color: #1db954; color: #000; border: none; padding: 8px 24px; border-radius: 500px; font-weight: bold; cursor: pointer;">Сохранить</button>
            </div>
        `;

        modal.appendChild(modalContent);
        document.body.appendChild(modal);

        document.getElementById('close-settings-modal').addEventListener('click', () => modal.remove());
        document.getElementById('save-settings-modal').addEventListener('click', () => {
            const isChecked = document.getElementById('setting-skip-liked').checked;
            settings.skipLikedTracks = isChecked;
            saveSettings();
            console.log('[Spotify Userscript] Настройки сохранены:', settings);
            modal.remove();
        });

        modal.addEventListener('click', (e) => {
            if (e.target === modal) modal.remove();
        });
    }

    // Создание плавающей кнопки шестеренки
    function createFloatingSettingsButton() {
        if (!document.body || document.getElementById('custom-floating-settings-btn')) return;

        const fBtn = document.createElement('button');
        fBtn.id = 'custom-floating-settings-btn';
        fBtn.setAttribute('aria-label', 'Настройки скрипта');
        fBtn.style.cssText = `
            position: fixed;
            bottom: 120px;
            right: 24px;
            width: 42px;
            height: 42px;
            background-color: #242424;
            border: none;
            border-radius: 50%;
            color: #b3b3b3;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5);
            z-index: 9998;
            transition: background-color 0.2s, color 0.2s, transform 0.2s;
        `;

        fBtn.innerHTML = `
            <svg role="img" height="20" width="20" aria-hidden="true" viewBox="0 0 16 16" fill="currentColor">
                <path d="M13.917 7A6.002 6.002 0 0 0 14 8c0 .34-.028.675-.083 1h1.333c.414 0 .75.336.75.75v.5a.75.75 0 0 1-.75.75H13.62a6.036 6.036 0 0 1-1.127 1.951l.945.945a.75.75 0 0 1 0 1.06l-.353.354a.75.75 0 0 1-1.06 0l-.946-.945A6.036 6.036 0 0 1 9.128 13.9H8c-.34 0-.675-.028-1-.083v1.333a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75V13.83A6.036 6.036 0 0 1 4.12 12.7l-.945.945a.75.75 0 0 1-1.061 0l-.353-.354a.75.75 0 0 1 0-1.06l.945-.946A6.036 6.036 0 0 1 2.083 9.35H.75A.75.75 0 0 1 0 8.6v-.5a.75.75 0 0 1 .75-.75h1.272c.055-.325.138-.642.247-.951l-.945-.945a.75.75 0 0 1 0-1.06l.353-.354a.75.75 0 0 1 1.06 0l.946.945A6.036 6.036 0 0 1 6.872 2.1H8c.34 0 .675.028 1 .083V.85A.75.75 0 0 1 9.75.1h.5a.75.75 0 0 1 .75.75v1.272a6.036 6.036 0 0 1 1.951 1.127l.945-.945a.75.75 0 0 1 1.061 0l.353.354a.75.75 0 0 1 0 1.06l-.945.946c.109.309.192.626.247.951H15.25a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-1.333zm-5.917 3a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>
            </svg>
        `;

        fBtn.addEventListener('mouseenter', () => {
            fBtn.style.backgroundColor = '#1db954';
            fBtn.style.color = '#000';
            fBtn.style.transform = 'scale(1.08)';
        });
        fBtn.addEventListener('mouseleave', () => {
            fBtn.style.backgroundColor = '#242424';
            fBtn.style.color = '#b3b3b3';
            fBtn.style.transform = 'scale(1)';
        });

        fBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            openSettingsModal();
        });

        document.body.appendChild(fBtn);
    }

    // Шаг 3: Внедрение кнопки дизлайка в DOM
    function insertDislikeButtons() {
        const addButtons = document.querySelectorAll([
            '.mD6MFF1cf5MA9Uhb button',
            '.K35IDBMaSDY689DK button',
            'button[aria-label*="любимые"]',
            'button[aria-label*="медиатеку"]',
            'button[aria-label*="Library"]',
            'button[aria-label*="Liked"]'
        ].join(','));

        addButtons.forEach(btn => {
            if (btn.nextElementSibling && btn.nextElementSibling.classList.contains('custom-dislike-button')) {
                return;
            }
            if (btn.classList.contains('custom-dislike-button')) {
                return;
            }

            const dislikeBtn = document.createElement('button');
            dislikeBtn.className = btn.className + ' custom-dislike-button';
            dislikeBtn.type = 'button';
            dislikeBtn.setAttribute('aria-label', 'Скрыть трек / Дизлайк');
            dislikeBtn.style.marginLeft = '8px';
            dislikeBtn.style.display = 'inline-flex';
            dislikeBtn.style.alignItems = 'center';
            dislikeBtn.style.justifyContent = 'center';

            dislikeBtn.innerHTML = `
                <span aria-hidden="true" class="e-10451-button__icon-wrapper">
                    <svg role="img" height="16" width="16" aria-hidden="true" viewBox="0 0 16 16" fill="currentColor" class="e-10451-icon">
                        <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
                        <path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8z"/>
                    </svg>
                </span>
            `;

            dislikeBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                handleDislikeAndSkip();
            });

            btn.parentNode.insertBefore(dislikeBtn, btn.nextSibling);
        });
    }

    // Слушатели клавиатуры: горячие клавиши и вывод отладки при F12
    window.addEventListener('keydown', (e) => {
        const activeEl = document.activeElement;
        if (activeEl && (
            activeEl.tagName === 'INPUT' ||
            activeEl.tagName === 'TEXTAREA' ||
            activeEl.isContentEditable
        )) {
            return;
        }

        // Вывод отладочной информации по кнопке F12 (клавиша F12 / код 123)
        if (e.key === 'F12' || e.keyCode === 123) {
            console.log('%c[Spotify Userscript Debug]', 'color: #1db954; font-weight: bold; font-size: 14px;');
            console.log('Статус skipLikedTracks:', settings.skipLikedTracks);

            const nowPlayingWidget = document.querySelector('[data-testid="now-playing-widget"]');
            console.log('Элемент now-playing-widget:', nowPlayingWidget);

            if (nowPlayingWidget) {
                const likeBtn = nowPlayingWidget.querySelector('button[aria-checked="true"]');
                console.log('Кнопка "Лайк" (aria-checked="true"):', likeBtn);
                if (likeBtn) {
                    console.log('HTML кнопки "Лайк":', likeBtn.outerHTML);
                }

                const trackId = getTrackIdentifier();
                console.log('Имя трека для отслеживания:', trackId);
                console.log('Последний пропущенный трек:', lastSkippedTrackUri);

                const progressInput = document.querySelector('[data-testid="playback-progressbar"] input');
                const progress = progressInput ? parseInt(progressInput.value, 10) : 0;
                console.log('Прогресс воспроизведения (мс):', progress);

                const positionEl = document.querySelector('[data-testid="playback-position"]');
                console.log('Отображаемое время:', positionEl ? positionEl.textContent : 'не найдено');

                const isStartOfTrackVal = (progressInput && progress < 5000) ||
                    (positionEl && ['0:00', '0:01', '0:02', '0:03', '0:04', '0:05'].includes(positionEl.textContent.trim()));
                console.log('Определен как старт трека (<5с):', isStartOfTrackVal);

                const isCooldownActive = Date.now() < skipCooldownUntil;
                console.log('Активен ли кулдаун переключения:', isCooldownActive);

                const shouldSkip = settings.skipLikedTracks && likeBtn && isStartOfTrackVal && (trackId !== lastSkippedTrackUri) && !isCooldownActive;
                console.log('Сработает ли автопропуск прямо сейчас:', shouldSkip);
            } else {
                console.log('Ошибка: Плеер не найден на странице.');
            }
            console.log('-----------------------------------------');
            return;
        }

        // Логика кнопки D / В
        if (e.key === 'd' || e.key === 'D' || e.key === 'в' || e.key === 'В' || e.code === 'KeyD') {
            console.log(`[Spotify Debug] Нажата горячая клавиша дизлайка: ${e.key} (${e.code})`);
            e.preventDefault();
            e.stopPropagation();
            handleDislikeAndSkip();
        }
    }, true);

    // Вешаем кулдаун при ручном клике на кнопки Назад/Вперед плеера
    window.addEventListener('click', (e) => {
        const skipBtn = e.target.closest('[data-testid="control-button-skip-forward"]');
        const prevBtn = e.target.closest('[data-testid="control-button-skip-back"]');
        if (skipBtn || prevBtn) {
            setSkipCooldown(1500);
        }
    }, true);

    // Запуск таймера для отслеживания старта песен в обход ограничений MutationObserver
    setInterval(checkAndSkipLikedTrack, 400);

    const observer = new MutationObserver(() => {
        modifyRemoteConfig();
        insertDislikeButtons();
        createFloatingSettingsButton();
        checkAndSkipLikedTrack();
    });

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

    window.addEventListener('DOMContentLoaded', () => {
        modifyRemoteConfig();
        insertDislikeButtons();
        createFloatingSettingsButton();
        checkAndSkipLikedTrack();
    });
})();