YouTube Grid Auto-Scroll & Search (Ultra Optimized - Instant)

Automatically scrolls to a specified text within a YouTube grid. Search box in masthead, search on button click only, stop button, animated border highlighting, and no results message. Improved robustness, accessibility, and user experience.

Tính đến 02-03-2025. Xem phiên bản mới nhất.

// ==UserScript==
// @name         YouTube Grid Auto-Scroll & Search (Ultra Optimized - Instant)
// @match        https://www.youtube.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @version      2.3
// @description  Automatically scrolls to a specified text within a YouTube grid. Search box in masthead, search on button click only, stop button, animated border highlighting, and no results message. Improved robustness, accessibility, and user experience.
// @author       Your Name (with further optimization)
// @license      MIT
// @namespace    https://greasyfork.org/users/1435316
// ==/UserScript==

(function() {
    'use strict';

    let targetText = "";
    let searchBox;
    let isSearching = false;
    let searchInput;
    let searchButton;
    let stopButton;
    let observer;
    let searchTimeout;
    const SEARCH_TIMEOUT_MS = 20000; // 20 seconds
    const SCROLL_DELAY_MS = 750;
    const MAX_SEARCH_LENGTH = 255; // Prevent excessively long search strings

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

        .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;
            border-radius: 8px;
            background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet);
            background-size: 400% 400%;
            animation: gradientAnimation 5s ease infinite;
            z-index: -1;
        }
        /* 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 */
         }

    `);


    // --- Create the Search Box ---
    function createSearchBox() {
        searchBox = document.createElement('div');
        searchBox.id = 'floating-search-box';
        searchBox.setAttribute('role', 'search'); // Add ARIA role for accessibility

        searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.placeholder = 'Search to scroll...';
        searchInput.value = GM_getValue('lastSearchTerm', '');
        searchInput.setAttribute('aria-label', 'Search within YouTube grid'); // ARIA label
        searchInput.maxLength = MAX_SEARCH_LENGTH; // Limit input length

        searchButton = document.createElement('button');
        searchButton.textContent = 'Search';
        searchButton.addEventListener('click', searchAndScroll);
        searchButton.setAttribute('aria-label', 'Start search'); // ARIA label

        stopButton = document.createElement('button');
        stopButton.textContent = 'Stop';
        stopButton.id = 'stop-search-button';
        stopButton.addEventListener('click', stopSearch);
        stopButton.setAttribute('aria-label', 'Stop search');  // ARIA label

        searchBox.appendChild(searchInput);
        searchBox.appendChild(searchButton);
        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("Could not find the YouTube masthead. Search box placed at top of page.");
            document.body.insertBefore(searchBox, document.body.firstChild);
        }

        // Trigger search on load if text is present and the URL indicates a channel page
        if (searchInput.value.trim() !== "" && window.location.href.includes("/videos")) {
            searchAndScroll();
        }
    }


    // --- Show Error Message ---
    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(() => {
            errorDiv.style.display = 'none';
        }, 5000); // Hide after 5 seconds
    }
    // --- Show "No Results" Message ---
    function showNoResultsMessage() {
        let noResultsDiv = document.getElementById('search-no-results-message');
        if (!noResultsDiv) {
            noResultsDiv = document.createElement('div');
            noResultsDiv.id = 'search-no-results-message';
            noResultsDiv.textContent = "No matching results found.";  // Set the text
            document.body.appendChild(noResultsDiv);
        }
    noResultsDiv.style.display = 'block';
     // Hide the message after a few seconds
        setTimeout(() => {
            noResultsDiv.style.display = 'none';
        }, 5000);

    }

    // --- Stop Search Function ---
    function stopSearch() {
        if (observer) {
            observer.disconnect();
        }
        isSearching = false;
        clearTimeout(searchTimeout);
        const prevHighlighted = document.querySelector('.highlighted-text');
        if (prevHighlighted) {
            prevHighlighted.classList.remove('highlighted-text');
        }
    }


    // --- Optimized Search and Scroll Function ---
    function searchAndScroll() {
        if (isSearching) return;
        isSearching = true;
        clearTimeout(searchTimeout);

        if (observer) {
            observer.disconnect();
        }

        targetText = searchInput.value.trim().toLowerCase();
        if (!targetText) {
            isSearching = false;
            return;
        }

        GM_setValue('lastSearchTerm', targetText);
         // Remove previous highlights
        const prevHighlighted = document.querySelector('.highlighted-text');
          if (prevHighlighted) {
            prevHighlighted.classList.remove('highlighted-text');
            prevHighlighted.style.position = '';  //remove inline styles, if any
        }

        let foundMatch = false; // Keep track of whether a match was found

        observer = new IntersectionObserver((entries) => {
            for (const entry of entries) {
                if (entry.isIntersecting) {
                    const titleElement = entry.target.querySelector('#video-title');  // More reliable selector
                    if (titleElement && titleElement.textContent.toLowerCase().includes(targetText)) {
                       foundMatch = true;
                       entry.target.scrollIntoView({ behavior: 'auto', block: 'center' });
                       entry.target.classList.add('highlighted-text');
                       observer.disconnect();
                       isSearching = false;
                       clearTimeout(searchTimeout);
                       return;
                    }
                }
            }
        });

        //Observe all grid items.
        document.querySelectorAll('ytd-rich-grid-media').forEach(item => {
            observer.observe(item);
        });

        searchTimeout = setTimeout(() => {
           stopSearch();
            if (!foundMatch) {
             showNoResultsMessage(); //show no results message.
            }
        }, SEARCH_TIMEOUT_MS);

        setTimeout(() => {
        if (!document.querySelector('.highlighted-text')) {
            window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'auto' });
            setTimeout(() => {
                if (!isSearching) return;  // Check again in case stop was pressed
                isSearching = false;
                searchAndScroll(); // Recursive call
            }, SCROLL_DELAY_MS);
        } else {
             isSearching = false;
         }
        }, 100);
    }

    // --- Initialization ---
    createSearchBox();

})();