Decluttered YouTube Search

Remove irrelevant/extraneous items from YouTube search results with a toggleable menu.

// ==UserScript==
// @name         Decluttered YouTube Search
// @namespace    http://github.com/dv-001
// @version      0.1.0
// @description  Remove irrelevant/extraneous items from YouTube search results with a toggleable menu.
// @author       dv-001
// @match        https://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @supportURL   https://github.com/dv-001/userscripts/issues
// @license MIT
// ==/UserScript==

(function () {
	'use strict';

	/* --- Configuration & State --- */

	// Easy-to-update selectors for various elements (since YouTube changes them often)
	const SELECTORS = {
		// 'People also search for', 'For you', etc.
		shelves: [
			'ytd-shelf-renderer',
			'ytd-horizontal-card-list-renderer[card-list-style=HORIZONTAL_CARD_LIST_STYLE_TYPE_NARROW_SHELF]'
		],
		// Rows of shorts
		shortsGrid: 'grid-shelf-view-model',
		// Individual shorts
		shorts: 'ytd-video-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style="SHORTS"])',
		// Watched videos
		watched: 'ytd-thumbnail-overlay-resume-playback-renderer #progress',
		// YouTube search bar
		searchbox: 'yt-searchbox'
	};

	const CONFIG = {
		hideShelves: GM_getValue('hideShelves', true),
		hideShortsGrid: GM_getValue('hideShortsGrid', true),
		hideShorts: GM_getValue('hideShorts', false),
		hideWatchedVideos: GM_getValue('hideWatchedVideos', false),
		watchedPercentage: GM_getValue('watchedPercentage', 90)
	};

	function updateConfig(key, value) {
		CONFIG[key] = value;
		GM_setValue(key, value);
		updateStyles(CONFIG);
	}

	/* --- Main Logic --- */

	// Our `<style>` element that will be injected into the page head
	const styleElement = document.createElement('style');
	styleElement.id = 'dyts-style';
	document.head.appendChild(styleElement);

	// Function to generate CSS rules based on current settings
	function updateStyles(config) {
		let css = '';
		if (config.hideShelves) {
			css += `${SELECTORS.shelves.join(', ')} { display: none !important; }\n`;
		}
		if (config.hideShortsGrid) {
			css += `${SELECTORS.shortsGrid} { display: none !important; }\n`;
		}
		if (config.hideShorts) {
			css += `${SELECTORS.shorts} { display: none !important; }\n`;
		}
		if (config.hideWatchedVideos) {
			let percentage = config.watchedPercentage;
			if (percentage && (percentage >= 1 && percentage <= 100)) {
				for (let i = percentage; i <= 100; i++) {
					css += `ytd-video-renderer:has(${SELECTORS.watched}[style*='width: ${i}%;']) `
						+ `{ display: none !important; }\n`;
				}
			}
		}
		styleElement.textContent = css;
	}

	function createSettingsUI() {
		if (document.getElementById('dyts-settings-container')) {
			return;
		}

		const container = document.createElement('div');
		container.id = 'dyts-settings-container';
		container.style.position = 'relative';

		// Button to open settings UI
		const gearButton = document.createElement('button');
		gearButton.id = 'dyts-settings-button';


		// Gear icon selector (so we don't have to store the path data ourselves)
		const gearSelector = 'iron-iconset-svg #settings path';
		const gearSvgPath = document.querySelector(gearSelector).getAttribute('d');

		const svgNS = 'http://www.w3.org/2000/svg';
		const svg = document.createElementNS(svgNS, 'svg');
		svg.setAttribute('viewBox', '0 0 24 24');
		svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
		svg.setAttribute('focusable', 'false');
		svg.style.pointerEvents = 'none';
		svg.style.display = 'block';
		svg.style.width = '100%';
		svg.style.height = '100%';
		const path = document.createElementNS(svgNS, 'path');
		path.setAttribute('clip-rule', 'evenodd');
		path.setAttribute('fill-rule', 'evenodd');
		path.setAttribute('d', gearSvgPath);

		svg.appendChild(path);
		gearButton.appendChild(svg);

		// Settings panel
		const panel = document.createElement('div');
		panel.id = 'dyts-settings-panel';
		// FIX: Explicitly set initial display state
		panel.style.display = 'none';

		const optionLabels = {
			hideShelves: 'Hide shelves (For You, People Also Watched, etc.)',
			hideShortsGrid: 'Hide Shorts grids',
			hideShorts: 'Hide individual Shorts',
			hideWatchedVideos: 'Hide videos more than'
		};

		for (const key in CONFIG) {
			if (typeof CONFIG[key] != 'boolean') {
				continue;
			}

			const row = document.createElement('div');
			row.className = 'dyts-setting-row';
			if (CONFIG[key]) {
				row.classList.add('dyts-setting-enabled');
			} else {
				row.classList.add('dyts-setting-disabled');
			}

			const label = document.createElement('label');

			const checkbox = document.createElement('input');
			checkbox.name = `dyts-checkbox-${key}`
			checkbox.type = 'checkbox';
			checkbox.checked = CONFIG[key];
			checkbox.dataset.key = key;
			checkbox.style.marginRight = '8px';

			checkbox.addEventListener('change', (e) => {
				updateConfig(e.target.dataset.key, e.target.checked);
				updateStyles(CONFIG);
			});

			label.appendChild(checkbox);
			label.append(optionLabels[key] || key);

			// Special logic for watched video percentage input
			if (key === 'hideWatchedVideos') {
				const percentageInput = document.createElement('input');
				percentageInput.disabled = !CONFIG.hideWatchedVideos;
				percentageInput.name = 'dyts-input-watched-percentage';
				percentageInput.type = 'number';
				percentageInput.min = '1';
				percentageInput.max = '100';
				percentageInput.value = CONFIG.watchedPercentage.toString();
				percentageInput.addEventListener('input', (e) => {
					const value = parseInt(e.target.value, 10);
					if (!isNaN(value) && value >= 1 && value <= 100) {
						updateConfig('watchedPercentage', value);
					}
				});

				const afterPercentageText = document.createElement('span');
				afterPercentageText.textContent = 'percent watched';

				label.appendChild(percentageInput);
				label.appendChild(afterPercentageText);

				// Update the input's disabled state when checkbox changes
				checkbox.addEventListener('change', (e) => {
					percentageInput.disabled = !e.target.checked;
				});
			}

			row.appendChild(label);
			panel.appendChild(row);
		}

		let panelVisible = false;

		gearButton.addEventListener('click', (e) => {
			e.stopPropagation();
			panelVisible = !panelVisible;
			panel.style.display = panelVisible ? 'flex' : 'none';
		});
		// Don't hide when clicking inside the panel
		panel.addEventListener('click', (e) => e.stopPropagation());
		document.addEventListener('click', () => {
			// Hide when clicking anywhere else
			panelVisible = false;
			panel.style.display = 'none';
		});

		// Insert the settings button next to the search bar
		container.appendChild(gearButton);
		container.appendChild(panel);
		const searchbox = document.querySelector(SELECTORS.searchbox);
		if (searchbox) {
			searchbox.appendChild(container);
		}
	}

	function removeSettingsUI() {
		const ui = document.getElementById('dyts-settings-container');
		if (ui) {
			ui.remove();
		}
	}

	// This function checks the URL and decides whether to add the gear/UI.
	// The stylesheet is always active, regardless of the page.
	function onPageChange() {
		const isSearchPage = window.location.pathname === '/results';
		if (isSearchPage) {
			// Use a small delay or an interval to wait for the search box to be ready
			const interval = setInterval(() => {
				if (document.querySelector(SELECTORS.searchbox)) {
					clearInterval(interval);
					createSettingsUI();
				}
			}, 100);
		} else {
			removeSettingsUI();
		}
	}

	/* --- Styling --- */

	function addGlobalStyles() {
		GM_addStyle(`
			#dyts-settings-button {
				background: none;
				border: none;
				cursor: pointer;
				width: 40px;
				height: 40px;
				padding: 8px;
				margin-left: 8px;
				border-radius: 50%;
				fill: var(--yt-spec-icon-inactive);
			}
				
			#dyts-settings-button:hover {
				background-color: var(--yt-spec-badge-chip-background);
			}

			#dyts-settings-panel {
				display: none;
				
				width: max-content;
				position: absolute;
				top: 5rem;
				right: 0;
				z-index: 9999;

				flex-direction: column;
				flex-wrap: nowrap;
				row-gap: 1rem;

				background-color: var(--yt-spec-static-overlay-additive-background);
				border: 2px solid var(--yt-spec-grey-4);
				border-radius: 1.5rem;
				padding: 1rem;
				box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6);
				backdrop-filter: blur(4rem);
			}

			#dyts-settings-panel h3 {
				margin: 0 0 10px 0;
				font-size: 1.75rem;
				color: var(--yt-spec-text-primary);
			}

			.dyts-setting-row label {
				display: flex;
				align-items: center;
				cursor: pointer;
				// padding: 6px 0;
				font-size: 1.6rem;
				color: var(--yt-spec-text-primary);
			}

			.dyts-setting-row .dyts-setting-enabled label {
				display: inherit;
			}

			dyts-setting-row .dyts-setting-disabled label {
				color: #f1f1f1b8
			}

			.dyts-setting-row label > span:first-of-type {
				flex-grow: 1; /* Allow the label text to push the input to the right */
			}

			.dyts-setting-row input[type="checkbox"] {
				margin-right: 12px;
				width: 18px;
				height: 18px;
				accent-color: var(--yt-spec-text-primary);
			}

			.dyts-setting-row input[type="number"] {
				width: 2.25rem;
				margin-left: 0.5rem;
				margin-right: 0.5rem;
				background-color: var(--yt-spec-badge-chip-background);
				border: 1px solid var(--yt-spec-10-percent-layer);
				color: var(--yt-spec-text-primary);
				border-radius: 0.5rem;
				padding: 0.25rem;
				text-align: center;
			}
		`);
	}

	/* --- Invoke --- */

	// Run on script load
	updateStyles(CONFIG);
	onPageChange();
	addGlobalStyles();

	window.addEventListener('yt-navigate-finish', onPageChange);
})();