// ==UserScript==
// @name YouTube Grid Auto-Scroll & Search (Ultra Optimized - Instant) - More Robust Visibility Handling
// @match https://www.youtube.com/*
// @grant GM_addStyle
// @version 3.3
// @description Automatically scrolls to the *next* matching video title, highlighting all matches. Search box, search/stop, next/previous buttons, animated border, no results message. Handles dynamic loading and browser minimize/restore correctly. Does NOT remember previous search.
// @author Your Name
// @license MIT
// @namespace https://gf.zukizuki.org/users/1435316
// ==/UserScript==
(function() {
'use strict';
let targetText = "";
let searchBox;
let isSearching = false;
let searchInput;
let searchButton;
let stopButton;
let prevButton;
let nextButton;
let scrollContinuationTimeout;
let overallSearchTimeout;
const SEARCH_TIMEOUT_MS = 20000;
const SCROLL_DELAY_MS = 750;
const MAX_SEARCH_LENGTH = 255;
let highlightedElements = [];
let currentHighlightIndex = -1;
let lastScrollHeight = 0;
let hasScrolledToResultThisSession = false;
GM_addStyle(`
/* Existing CSS (with additions) */
#floating-search-box {
background-color: #222;
padding: 5px;
border: 1px solid #444;
border-radius: 5px;
display: flex;
align-items: center;
margin-left: 10px;
}
/* Responsive width for smaller screens */
@media (max-width: 768px) {
#floating-search-box input[type="text"] {
width: 150px; /* Smaller width on smaller screens */
}
}
#floating-search-box input[type="text"] {
background-color: #333;
color: #fff;
border: 1px solid #555;
padding: 3px 5px;
border-radius: 3px;
margin-right: 5px;
width: 200px;
height: 30px;
}
#floating-search-box input[type="text"]:focus {
outline: none;
border-color: #065fd4;
}
#floating-search-box button {
background-color: #065fd4;
color: white;
border: none;
padding: 3px 8px;
border-radius: 3px;
cursor: pointer;
height: 30px;
}
#floating-search-box button:hover {
background-color: #0549a8;
}
#floating-search-box button:focus {
outline: none;
}
#stop-search-button {
background-color: #aa0000; /* Red color */
}
#stop-search-button:hover {
background-color: #800000;
}
/* Style for navigation buttons */
#prev-result-button, #next-result-button {
background-color: #444;
color: white;
margin: 0 3px; /* Add some spacing */
}
#prev-result-button:hover, #next-result-button:hover {
background-color: #555;
}
.highlighted-text {
position: relative; /* Needed for the border to be positioned correctly */
z-index: 1; /* Ensure the border is on top of other elements */
}
/* Creates the animated border effect */
.highlighted-text::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border: 2px solid transparent; /* Transparent border to start */
border-radius: 8px; /* Rounded corners */
background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet); /* Rainbow gradient */
background-size: 400% 400%; /* Make the gradient larger than the element */
animation: gradientAnimation 5s ease infinite; /* Animate the background position */
z-index: -1; /* Behind the content */
}
/* Keyframes for the gradient animation */
@keyframes gradientAnimation {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Style for the error message */
#search-error-message {
color: red;
font-weight: bold;
padding: 5px;
position: fixed; /* Fixed position */
top: 50px; /* Position below the masthead (adjust as needed)*/
left: 50%;
transform: translateX(-50%); /* Center horizontally */
background-color: rgba(0, 0, 0, 0.8); /* Semi-transparent black */
color: white;
border-radius: 5px;
z-index: 10000; /* Ensure it's on top */
display: none; /* Initially hidden */
}
/* Style for the no results message */
#search-no-results-message {
color: #aaa; /* Light gray */
padding: 5px;
position: fixed;
top: 50px; /* Same position as error message */
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
border-radius: 5px;
z-index: 10000;
display: none; /* Initially hidden */
}
`);
function ensureSearchBoxAttached() {
if (searchBox && !document.body.contains(searchBox)) {
console.log("YouTube Grid Auto-Scroll: Search box detached, attempting to re-attach.");
const mastheadEnd = document.querySelector('#end.ytd-masthead');
const buttonsContainer = document.querySelector('#end #buttons');
if (mastheadEnd) {
if (buttonsContainer) {
mastheadEnd.insertBefore(searchBox, buttonsContainer);
} else {
mastheadEnd.appendChild(searchBox);
}
} else {
console.error("YouTube Grid Auto-Scroll: Could not find masthead to re-attach search box. Search UI might not be visible.");
if (!document.body.contains(searchBox)) {
document.body.insertBefore(searchBox, document.body.firstChild);
}
}
}
}
function createSearchBox() {
searchBox = document.createElement('div');
searchBox.id = 'floating-search-box';
searchBox.setAttribute('role', 'search');
searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Поиск для прокрутки...';
searchInput.value = '';
searchInput.setAttribute('aria-label', 'Search within YouTube grid');
searchInput.maxLength = MAX_SEARCH_LENGTH;
searchButton = document.createElement('button');
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', () => {
stopSearch();
isSearching = true;
hasScrolledToResultThisSession = false;
currentHighlightIndex = -1; // Ensure search starts from the first match
searchAndScroll();
});
searchButton.setAttribute('aria-label', 'Start search');
prevButton = document.createElement('button');
prevButton.textContent = 'Пред.';
prevButton.id = 'prev-result-button';
prevButton.addEventListener('click', () => navigateResults(-1));
prevButton.setAttribute('aria-label', 'Previous result');
prevButton.disabled = true;
nextButton = document.createElement('button');
nextButton.textContent = 'След.';
nextButton.id = 'next-result-button';
nextButton.addEventListener('click', () => navigateResults(1));
nextButton.disabled = true;
stopButton = document.createElement('button');
stopButton.textContent = 'Стоп';
stopButton.id = 'stop-search-button';
stopButton.addEventListener('click', stopSearch);
stopButton.setAttribute('aria-label', 'Stop search');
searchBox.appendChild(searchInput);
searchBox.appendChild(searchButton);
searchBox.appendChild(prevButton);
searchBox.appendChild(nextButton);
searchBox.appendChild(stopButton);
const mastheadEnd = document.querySelector('#end.ytd-masthead');
const buttonsContainer = document.querySelector('#end #buttons');
if (mastheadEnd) {
if(buttonsContainer){
mastheadEnd.insertBefore(searchBox, buttonsContainer);
} else{
mastheadEnd.appendChild(searchBox);
}
} else {
console.error("Could not find the YouTube masthead's end element.");
showErrorMessage("Не удалось найти шапку YouTube. Блок поиска размещен вверху страницы.");
document.body.insertBefore(searchBox, document.body.firstChild);
}
}
function showErrorMessage(message) {
let errorDiv = document.getElementById('search-error-message');
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.id = 'search-error-message';
document.body.appendChild(errorDiv);
}
errorDiv.textContent = message;
errorDiv.style.display = 'block';
setTimeout(() => { if(errorDiv) errorDiv.style.display = 'none'; }, 5000);
}
function showNoResultsMessage() {
let noResultsDiv = document.getElementById('search-no-results-message');
if (!noResultsDiv) {
noResultsDiv = document.createElement('div');
noResultsDiv.id = 'search-no-results-message';
noResultsDiv.textContent = "Совпадений не найдено.";
document.body.appendChild(noResultsDiv);
}
noResultsDiv.style.display = 'block';
setTimeout(() => { if(noResultsDiv) noResultsDiv.style.display = 'none'; }, 5000);
}
function stopSearch() {
isSearching = false;
// hasScrolledToResultThisSession = false; // Should be reset when a new search *starts*
clearTimeout(scrollContinuationTimeout);
clearTimeout(overallSearchTimeout);
currentHighlightIndex = -1;
highlightedElements = [];
document.querySelectorAll('.highlighted-text').forEach(el => {
el.classList.remove('highlighted-text');
});
updateNavButtons();
}
function navigateResults(direction) {
if (highlightedElements.length === 0) return;
currentHighlightIndex += direction;
if (currentHighlightIndex < 0) currentHighlightIndex = highlightedElements.length - 1;
else if (currentHighlightIndex >= highlightedElements.length) currentHighlightIndex = 0;
if (highlightedElements[currentHighlightIndex]) {
highlightedElements[currentHighlightIndex].scrollIntoView({ behavior: 'auto', block: 'center' });
hasScrolledToResultThisSession = true; // User actively navigated
}
updateNavButtons();
}
function updateNavButtons() {
prevButton.disabled = highlightedElements.length <= 1;
nextButton.disabled = highlightedElements.length <= 1;
}
function searchAndScroll() {
if (searchBox && !document.body.contains(searchBox)) {
console.warn("YouTube Grid Auto-Scroll: Search box is not in the document. Stopping script operations.");
showErrorMessage("UI поиска потерян. Пожалуйста, перезагрузите страницу или попробуйте переустановить скрипт.");
stopSearch(); // Full stop, including clearing isSearching
return;
}
if (!isSearching) { // If not in an active search session, do nothing further.
clearTimeout(scrollContinuationTimeout);
clearTimeout(overallSearchTimeout);
return;
}
clearTimeout(scrollContinuationTimeout);
targetText = searchInput.value.trim().toLowerCase();
if (!targetText) {
stopSearch(); // This sets isSearching = false
return;
}
clearTimeout(overallSearchTimeout); // Clear previous overall timeout
overallSearchTimeout = setTimeout(() => {
if (isSearching) { // Check if search is still considered active
showErrorMessage("Поиск прерван по таймауту.");
stopSearch(); // This sets isSearching = false
}
}, SEARCH_TIMEOUT_MS);
document.querySelectorAll('.highlighted-text').forEach(el => {
el.classList.remove('highlighted-text');
});
const mediaElements = Array.from(document.querySelectorAll('ytd-rich-grid-media:not([style*="display: none"])'));
let newlyFoundHighlightedElements = [];
for (let i = 0; i < mediaElements.length; i++) {
const titleElement = mediaElements[i].querySelector('#video-title');
if (titleElement && titleElement.textContent.toLowerCase().includes(targetText)) {
mediaElements[i].classList.add('highlighted-text');
newlyFoundHighlightedElements.push(mediaElements[i]);
}
}
highlightedElements = newlyFoundHighlightedElements; // Update global list for Prev/Next
updateNavButtons();
let elementToScrollTo = null;
let newActiveHighlightIndex = -1;
if (highlightedElements.length > 0) {
if (currentHighlightIndex === -1 || currentHighlightIndex >= highlightedElements.length) {
// This case is for initial search (currentHighlightIndex is -1 from search button or stopSearch)
// or if the previous index is now out of bounds for the new set of highlights.
newActiveHighlightIndex = 0; // Target the first found item
} else {
// This case is for subsequent calls to searchAndScroll during an active auto-scroll session
// (e.g., after page scroll and new items are loaded).
// currentHighlightIndex holds the index from the *previous successful find*.
// We want to scroll to the *next* item in the *current* set of highlightedElements.
newActiveHighlightIndex = (currentHighlightIndex + 1) % highlightedElements.length;
}
elementToScrollTo = highlightedElements[newActiveHighlightIndex];
}
if (elementToScrollTo) {
elementToScrollTo.scrollIntoView({ behavior: 'auto', block: 'center' });
currentHighlightIndex = newActiveHighlightIndex; // Update the global current index
hasScrolledToResultThisSession = true;
isSearching = false; // Found a match, pause active auto-searching. User can use Prev/Next or start new search.
clearTimeout(overallSearchTimeout); // Successful find, clear overall timeout.
} else {
// No matches found in the current viewport (or highlightedElements is empty).
// Continue searching by scrolling down, but only if isSearching is still true.
if (!isSearching) { // If isSearching was set to false by other means (e.g. stop button during processing)
clearTimeout(overallSearchTimeout);
return;
}
lastScrollHeight = document.documentElement.scrollHeight;
window.scrollTo({ top: lastScrollHeight, behavior: 'auto' });
scrollContinuationTimeout = setTimeout(() => {
if (!isSearching) return; // Stop if search was cancelled during this timeout
if (document.documentElement.scrollHeight === lastScrollHeight) {
// Reached end of page, and no new content loaded.
const searchWasActiveBeforeStop = isSearching; // Capture state
stopSearch(); // End the search session (sets isSearching = false)
if (searchWasActiveBeforeStop && !hasScrolledToResultThisSession && targetText) {
showNoResultsMessage();
}
} else {
// New content might have loaded. isSearching is still true.
// currentHighlightIndex might point to the last found item.
// The next call to searchAndScroll will try to find the item after currentHighlightIndex.
searchAndScroll(); // Continue searching
}
}, SCROLL_DELAY_MS);
}
}
// --- Event Listener for Visibility Change ---
document.addEventListener('visibilitychange', function() {
if (!document.hidden) { // Tab is now visible
ensureSearchBoxAttached();
// If a search was (or was intended to be) active when tab was hidden,
// robustly restart the search process.
if (isSearching || (searchInput && searchInput.value.trim() && currentHighlightIndex !== -1) ) {
// The condition `currentHighlightIndex !== -1` might indicate a previous successful search session
// whose state we want to refresh or continue if the user expects it.
// However, the most robust way if `isSearching` was true is a full restart.
// If `isSearching` is false but there's text and a highlight, user might expect current view.
// Let's primarily focus on restarting if `isSearching` was true (meaning an operation was pending).
const wasSearchingBeforeVisibilityChange = isSearching; // Capture the flag
const currentSearchTermInBox = searchInput ? searchInput.value : "";
if (wasSearchingBeforeVisibilityChange) {
console.log("YouTube Grid Auto-Scroll: Tab became visible during an active search. Restarting search process.");
stopSearch(); // Fully reset state: sets isSearching=false, clears timers, highlights, currentHighlightIndex.
if (searchInput) searchInput.value = currentSearchTermInBox; // Restore text if stopSearch cleared it (it doesn't)
if (currentSearchTermInBox.trim()) {
isSearching = true; // Re-enable searching flag
hasScrolledToResultThisSession = false; // Reset for this new search session
currentHighlightIndex = -1; // Ensure search starts from the very first match
searchAndScroll(); // Start a fresh search
}
}
// If !wasSearchingBeforeVisibilityChange, we assume the script was idle or had completed a search.
// In that case, we don't automatically restart anything. User can initiate new search if needed.
}
}
// No special action when tab is hidden; existing timeouts will be throttled by browser.
});
// --- Initialization ---
if (document.readyState === 'complete' || document.readyState === 'interactive') {
createSearchBox();
} else {
document.addEventListener('DOMContentLoaded', createSearchBox);
}
})();