Spotify Dislike Button

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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();
    });
})();