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

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. Uses jQuery. Works in background tabs.

目前为 2025-03-03 提交的版本,查看 最新版本

// ==UserScript==
// @name         YouTube Grid Auto-Scroll & Search (Ultra Optimized - Instant) - jQuery & Background
// @match        https://www.youtube.com/*
// @grant        GM_addStyle
// @version      3.1
// @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. Uses jQuery.  Works in background tabs.
// @author       Your Name (with further optimization & jQuery)
// @license      MIT
// @namespace    https://greasyfork.org/users/1435316
// @require      https://code.jquery.com/jquery-3.7.1.min.js
// ==/UserScript==

(function() {
    'use strict';

    let targetText = "";
    let $searchBox;
    let isSearching = false;
    let $searchInput;
    let $searchButton;
    let $stopButton;
    let $prevButton;
    let $nextButton;
    let searchTimeout;
    const SEARCH_TIMEOUT_MS = 20000; // 20 seconds
    const SCROLL_DELAY_MS = 750;
    const MAX_SEARCH_LENGTH = 255;
    let highlightedElements = []; // Array to store all highlighted elements (jQuery objects)
    let currentHighlightIndex = -1; // Index of *currently* highlighted element
    let lastScrollHeight = 0;
    let observer = null;  // MutationObserver
    let isPaused = false; // Flag to pause the observer


    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 */
         }

    `);

    // --- Create the Search Box ---
    function createSearchBox() {
        $searchBox = $('<div>', {
            id: 'floating-search-box',
            role: 'search'
        });

        $searchInput = $('<input>', {
            type: 'text',
            placeholder: 'Search to scroll...',
            value: '',
            'aria-label': 'Search within YouTube grid',
            maxlength: MAX_SEARCH_LENGTH
        });

        $searchButton = $('<button>', {
            text: 'Search',
            'aria-label': 'Start search',
            click: searchAndScroll
        });

        $prevButton = $('<button>', {
            text: 'Prev',
            id: 'prev-result-button',
            'aria-label': 'Previous result',
            disabled: true,
            click: () => navigateResults(-1)
        });

        $nextButton = $('<button>', {
            text: 'Next',
            id: 'next-result-button',
            'aria-label': 'Next result',
            disabled: true,
            click: () => navigateResults(1)
        });

        $stopButton = $('<button>', {
            text: 'Stop',
            id: 'stop-search-button',
            'aria-label': 'Stop search',
            click: stopSearch
        });

        $searchBox.append($searchInput, $searchButton, $prevButton, $nextButton, $stopButton);

        const $mastheadEnd = $('#end.ytd-masthead');
        const $buttonsContainer = $('#end #buttons');

        if ($mastheadEnd.length) {
            if ($buttonsContainer.length) {
                $searchBox.insertBefore($buttonsContainer);
            } else {
                $mastheadEnd.append($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.");
            $('body').prepend($searchBox); // Fallback
        }
    }


    // --- Show Error Message ---
    function showErrorMessage(message) {
        let $errorDiv = $('#search-error-message');
        if (!$errorDiv.length) {
            $errorDiv = $('<div>', { id: 'search-error-message' }).appendTo('body');
        }
        $errorDiv.text(message).show();

        setTimeout(() => $errorDiv.hide(), 5000);
    }

    // --- Show "No Results" Message ---
    function showNoResultsMessage() {
        let $noResultsDiv = $('#search-no-results-message');
        if (!$noResultsDiv.length) {
            $noResultsDiv = $('<div>', { id: 'search-no-results-message', text: "No matching results found." }).appendTo('body');
        }
        $noResultsDiv.show();

        setTimeout(() => $noResultsDiv.hide(), 5000);
    }


    // --- Stop Search Function ---
    function stopSearch() {
        isSearching = false;
        clearTimeout(searchTimeout);
        currentHighlightIndex = -1;
        highlightedElements.forEach($el => $el.removeClass('highlighted-text').css('position', ''));
        highlightedElements = []; // Clear the array
        updateNavButtons();
        resumeObserver();  // Resume when search stops
    }

    // --- Navigate Between Results ---
    function navigateResults(direction) {
        if (highlightedElements.length === 0) return;

        currentHighlightIndex += direction;

        if (currentHighlightIndex < 0) {
            currentHighlightIndex = highlightedElements.length - 1;
        } else if (currentHighlightIndex >= highlightedElements.length) {
            currentHighlightIndex = 0;
        }

        highlightedElements[currentHighlightIndex][0].scrollIntoView({ behavior: 'auto', block: 'center' });
        updateNavButtons();
    }

    // --- Update Navigation Buttons State ---
    function updateNavButtons() {
        $prevButton.prop('disabled', highlightedElements.length <= 1);
        $nextButton.prop('disabled', highlightedElements.length <= 1);
    }


    // --- Pause the observer ---
    function pauseObserver() {
        if (observer && !isPaused) {
            observer.disconnect();
            isPaused = true;
            //console.log("Observer paused");
        }
    }

    // --- Resume the observer ---
    function resumeObserver() {
        if (observer && isPaused) {
             observeDOM(); // Re-connect
            isPaused = false;
            //console.log("Observer resumed");
        }
    }


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

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

        pauseObserver(); // Pause the observer during active search

        // Get all media elements (regardless of visibility)
        const $mediaElements = $('ytd-rich-grid-media');  // No :visible check
        let foundMatch = false;

        highlightedElements = []; // Clear previous results
        $mediaElements.each(function() {
            const $titleElement = $(this).find('#video-title');
            if ($titleElement.length && $titleElement.text().toLowerCase().includes(targetText)) {
                $(this).addClass('highlighted-text');
                highlightedElements.push($(this));
                foundMatch = true;
            } else {
                $(this).removeClass('highlighted-text'); // Remove highlighting if no match
            }
        });


        // Find the next match index
        let nextMatchIndex = -1;
        if(currentHighlightIndex !== -1 && highlightedElements.length > 0) {
            // Start searching from the element *after* the current one in highlightedElements
            for(let i = currentHighlightIndex + 1; i < highlightedElements.length; i++) {
                if(highlightedElements[i].is(":visible")){ //Only consider visible ones for scrolling
                    nextMatchIndex = i;
                    break;
                }
            }

        } else {
             // If no previous match, find the *first* visible one
            for (let i = 0; i < highlightedElements.length; i++) {
                if (highlightedElements[i].is(":visible")) {
                    nextMatchIndex = i;
                    break;
                }
            }
        }


        if (nextMatchIndex !== -1) {
             // Scroll to the *next* match
            highlightedElements[nextMatchIndex][0].scrollIntoView({ behavior: 'auto', block: 'center' });
            currentHighlightIndex = nextMatchIndex;
            updateNavButtons();
            isSearching = false; // Stop searching after first visible match

        } else {
              // No new *visible* match found, scroll down
            lastScrollHeight = document.documentElement.scrollHeight;
             // Use native scrollTo for background tab compatibility
            window.scrollTo({ top: lastScrollHeight, behavior: 'auto' });

            searchTimeout = setTimeout(() => {
                if (!isSearching) return; // Check again, in case stopSearch was called

                 if (document.documentElement.scrollHeight === lastScrollHeight) { // End of page
                    stopSearch();
                    if (!foundMatch) {
                        showNoResultsMessage();
                    }
                } else {
                    isSearching = false;  // Allow re-entry
                    searchAndScroll();    // Continue searching after scroll
                }
            }, SCROLL_DELAY_MS);
        }

        searchTimeout = setTimeout(() => {
            stopSearch();
            showErrorMessage("Search timed out.");
        }, SEARCH_TIMEOUT_MS);
    }


    // --- Mutation Observer ---
    function observeDOM() {
        if (observer) {
            observer.disconnect(); // Disconnect any existing observer
        }

        observer = new MutationObserver(mutations => {
            if (!isSearching) { // Only re-run search if NOT actively searching
                searchAndScroll(); // Re-evaluate on DOM changes
            }
        });

        // Observe the entire document body for changes
        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['style', 'class'] // Observe style and class changes
        });
    }



    // --- Initialization ---
    createSearchBox();
    observeDOM(); // Start observing


})();