Greasy Fork is available in English.
добавляет кнопку дизлайка, горячие клавиши D/В и плавающей шестерёнки настроек с функцией пропуска лайкнутых треков
// ==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;">×</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();
});
})();