YouTube Playlist Search Bar (Inchbrayock's Branch)

Adds a search bar to YouTube playlists. Search by title or channel name (using @). Does NOT work with shorts or when playlist video filter is set to "Shorts".

// ==UserScript==
// @name YouTube Playlist Search Bar (Inchbrayock's Branch)
// @namespace http://tampermonkey.net/
// @version 1.1
// @description Adds a search bar to YouTube playlists. Search by title or channel name (using @). Does NOT work with shorts or when playlist video filter is set to "Shorts".
// @match https://www.youtube.com/playlist*
// @author Setnour6
// @author Inchbrayock
// @homepageURL https://github.com/Setnour6/Helpful-Userscripts
// @grant none
// @license GPL-3.0
// ==/UserScript==

(function () {
    'use strict';

    // Array of placeholder texts that will be animated in the search input
    const PLACEHOLDERS = [
        'Search in playlist...',
        'Type @ to search by channel...',
        'Find your favorite videos...',
        'Search through your collection...',
        'Looking for something specific?',
        'Filter playlist content...'
    ];

    // CSS styles for the search bar components
    const STYLES = {
        container: `
            display: flex;
            align-items: center;
            margin: 24px auto 20px;
            padding: 0 24px;
            width: 100%;
            max-width: 800px;
            box-sizing: border-box;
            transition: all 0.3s ease;
            `,
        searchContainer: `
            display: flex;
            align-items: center;
            width: 100%;
            background-color: var(--yt-spec-badge-chip-background);
            border-radius: 12px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
            transition: all 0.3s ease;
            border: 1px solid rgba(255, 255, 255, 0.1);
            `,
        input: `
            flex: 1;
            padding: 14px 20px;
            border: none;
            border-radius: 12px;
            color: var(--yt-spec-text-primary);
            background-color: transparent;
            font-family: 'YouTube Sans', Roboto, sans-serif;
            font-size: 15px;
            height: 48px;
            box-sizing: border-box;
            outline: none;
            transition: all 0.3s ease;
            `
    };

    // Global variables to track state
    let originalVideos = []; // Stores the original list of videos before filtering
    let isTyping = true; // Controls typing animation direction (typing vs deleting)
    let isAnimating = false; // Prevents multiple animations from running simultaneously
    let currentPlaceholderIndex = 0; // Tracks which placeholder text is currently being shown

    // Handles the animated typing effect for placeholder text
    async function animatePlaceholder(input) {
        if (isAnimating) return;
        isAnimating = true;

        while (document.getElementById('playlist-search-input') && !input.value && !input.matches(':focus')) {
            const placeholder = PLACEHOLDERS[currentPlaceholderIndex];

            if (isTyping) {
                // Typing effect
                input.placeholder = ''; // Clear before starting
                for (let i = 0; i <= placeholder.length; i++) {
                    if (!document.getElementById('playlist-search-input') || input.value || input.matches(':focus')) {
                        isAnimating = false;
                        return;
                    }
                    input.placeholder = placeholder.slice(0, i);
                    await new Promise(resolve => setTimeout(resolve, 50));
                }
                await new Promise(resolve => setTimeout(resolve, 2000));
                isTyping = false;
            } else {
                // Deleting effect
                for (let i = placeholder.length; i >= 0; i--) {
                    if (!document.getElementById('playlist-search-input') || input.value || input.matches(':focus')) {
                        isAnimating = false;
                        return;
                    }
                    input.placeholder = placeholder.slice(0, i);
                    await new Promise(resolve => setTimeout(resolve, 30));
                }
                currentPlaceholderIndex = (currentPlaceholderIndex + 1) % PLACEHOLDERS.length;
                isTyping = true;
                await new Promise(resolve => setTimeout(resolve, 500));
            }
        }

        isAnimating = false;
    }

    // Handles visual changes when the search input is focused
    function handleFocus(searchContainer, input) {
        searchContainer.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.12)';
        searchContainer.style.transform = 'translateY(-1px)';
        searchContainer.style.borderColor = 'rgba(255, 255, 255, 0.2)';
        input.placeholder = '';
    }

    // Handles visual changes when the search input loses focus
    function handleBlur(searchContainer, input) {
        searchContainer.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
        searchContainer.style.transform = 'none';
        searchContainer.style.borderColor = 'rgba(255, 255, 255, 0.1)';

        // Only restart animation if the input is empty
        if (!input.value) {
            // Reset all animation states
            isAnimating = false;
            isTyping = true;
            currentPlaceholderIndex = 0;
            input.placeholder = '';
            setTimeout(() => {
                if (!input.value && !input.matches(':focus')) {
                    animatePlaceholder(input);
                }
            }, 500); // Delay restart of animation
        }
    }

    // Filters the playlist videos based on search query
    function filterVideos(query) {
        const playlistContents = document.querySelector('#contents.ytd-playlist-video-list-renderer');
        if (!playlistContents) return;

        // Store original videos on first search
        if (originalVideos.length === 0) {
            originalVideos = Array.from(document.querySelectorAll('#contents ytd-playlist-video-renderer'));
        }

        // Check if searching by channel name (using @) or video title
        const isChannelSearch = query.startsWith('@');
        const searchQuery = isChannelSearch ? query.slice(1).toLowerCase() : query.toLowerCase();

        // Filter videos based on search criteria
        const matchingVideos = originalVideos.filter(video => {
            const title = video.querySelector('#video-title').textContent.toLowerCase();
            const channelName = video.querySelector('#channel-name a').textContent.toLowerCase();
            return isChannelSearch ? channelName.includes(searchQuery) : title.includes(searchQuery);
        });

        updatePlaylistContents(playlistContents, matchingVideos);
    }

    // Resets the playlist to show all videos
    function resetFilter() {
        const playlistContents = document.querySelector('#contents.ytd-playlist-video-list-renderer');
        if (!playlistContents) return;
        updatePlaylistContents(playlistContents, originalVideos);
    }

    // Updates the playlist container with filtered videos
    function updatePlaylistContents(container, videos) {
        // Clear the container first
        container.textContent = '';

        // Process videos in batches
        const BATCH_SIZE = 10;
        let currentIndex = 0;

        function processBatch() {
            const batch = videos.slice(currentIndex, currentIndex + BATCH_SIZE);
            if (batch.length === 0) return;

            requestAnimationFrame(() => {
                batch.forEach(video => container.appendChild(video));
                currentIndex += BATCH_SIZE;

                // Schedule next batch
                if (currentIndex < videos.length) {
                    setTimeout(processBatch, 16); // Roughly aims for 60fps
                }
            });
        }

        processBatch();
    }

    /**
    * Scrolls the page to load all videos in the playlist.
    * Keeps scrolling until the number of loaded <ytd-playlist-video-renderer> nodes
    * stops increasing for two consecutive passes.
    */
    async function loadAllVideos() {
        const selector = '#contents ytd-playlist-video-renderer';
        let prevCount = 0;
        let stablePasses = 0;
        // keep scrolling until count stabilizes twice
        while (stablePasses < 2) {
            window.scrollTo(0, document.documentElement.scrollHeight);
            // allow YouTube's infinite‑scroll to fetch more items
            await new Promise(res => setTimeout(res, 1000));
            const currentCount = document.querySelectorAll(selector).length;
            if (currentCount === prevCount) {
                stablePasses++;
            } else {
                stablePasses = 0;
                prevCount = currentCount;
            }
        }
    }

    // Creates and injects the search bar into the YouTube playlist page
    function createSearchBar() {
        // Prevent duplicate search bars
        if (document.getElementById('playlist-search-bar')) return;

        const target = document.querySelector('#page-manager ytd-playlist-video-list-renderer');
        if (!target) return;

        const container = document.createElement('div');
        container.id = 'playlist-search-bar';
        container.style.cssText = STYLES.container;

        const searchContainer = document.createElement('div');
        searchContainer.style.cssText = STYLES.searchContainer;

        const input = document.createElement('input');
        input.id = 'playlist-search-input';
        input.placeholder = '';
        input.style.cssText = STYLES.input;

        input.addEventListener('focus', () => handleFocus(searchContainer, input));
        input.addEventListener('blur', () => handleBlur(searchContainer, input));
        input.addEventListener('input', (e) => {
            const query = e.target.value.trim();
            query ? filterVideos(query) : resetFilter();
        });

        // New: on Enter, first load all videos, then redo the filter
        input.addEventListener('keydown', async (e) => {
            if (e.key === 'Enter') {
            e.preventDefault();
            const query = input.value.trim();
            // clear any previous cache so we recapture everything
            originalVideos = [];
            // load all the videos in the playlist
            await loadAllVideos();
            // rebuild originalVideos from the fully‑loaded list
            originalVideos = Array.from(document.querySelectorAll('#contents ytd-playlist-video-renderer'));
            // reapply the current filter (or reset if empty)
            query ? filterVideos(query) : resetFilter();
            }
        });

        searchContainer.appendChild(input);
        container.appendChild(searchContainer);
        target.parentNode.insertBefore(container, target);

        requestAnimationFrame(() => animatePlaceholder(input));
    }

    // Initialize the script
    // Use MutationObserver to handle YouTube's dynamic page loading
    const observer = new MutationObserver(createSearchBar);
    observer.observe(document.body, { childList: true, subtree: true });
    createSearchBar();
})();