YouTube Subscriptions Filter

Filter YouTube subscriptions using simple Google-style syntax

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

Advertisement:

// ==UserScript==
// @name         YouTube Subscriptions Filter
// @namespace    local.youtube.subscriptions.filter
// @version      0.3
// @description  Filter YouTube subscriptions using simple Google-style syntax
// @match        https://www.youtube.com/feed/subscriptions*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    const STYLE_ID = "yt-sub-filter-style";

    const INLINE_BAR_ID = "yt-sub-filter-bar";
    const FLOAT_BAR_ID = "yt-sub-filter-float";

    const INLINE_INPUT_ID = "yt-sub-filter-input";
    const FLOAT_INPUT_ID = "yt-sub-filter-input-float";
    const AUTOCOMPLETE_ID = "yt-sub-filter-autocomplete";

    const FIELD_TOKENS = ["title", "channel", "text"];

    let currentQuery = "";
    let autocompleteIndex = 0;
    let applyFilterQueued = false;

    function addStyles() {
        if (document.getElementById(STYLE_ID)) return;

        const style = document.createElement("style");

        style.id = STYLE_ID;

        style.textContent = `
            #${INLINE_BAR_ID} {
                width: 100%;
                box-sizing: border-box;
                padding: 12px 24px;
                background: var(--yt-spec-base-background, #fff);
            }

            #${FLOAT_BAR_ID} {
                position: fixed;
                top: 70px;
                left: 50%;
                transform: translateX(-50%);
                z-index: 999999;
                padding: 10px;
                border-radius: 14px;
                background: rgba(20,20,20,0.92);
                backdrop-filter: blur(8px);

                opacity: 0;
                pointer-events: none;
                transition: opacity 0.15s ease;
            }

            #${FLOAT_BAR_ID}.visible {
                opacity: 1;
                pointer-events: auto;
            }

            body.yt-sub-filter-float-visible #${INLINE_BAR_ID} {
                display: none;
            }

            #${INLINE_INPUT_ID},
            #${FLOAT_INPUT_ID} {
                width: min(700px, 80vw);
                box-sizing: border-box;
                padding: 10px 14px;
                border-radius: 20px;
                border: 1px solid #666;
                font-size: 14px;
                outline: none;
            }

            #${FLOAT_INPUT_ID} {
                background: #111;
                color: white;
            }

            #${AUTOCOMPLETE_ID} {
                position: fixed;
                z-index: 1000000;
                min-width: 180px;
                max-height: 320px;
                overflow-y: auto;
                padding: 6px;
                border-radius: 10px;
                background: rgba(20,20,20,0.96);
                color: white;
                box-shadow: 0 8px 24px rgba(0,0,0,0.35);
                display: none;
            }

            #${AUTOCOMPLETE_ID}.visible {
                display: block;
            }

            #${AUTOCOMPLETE_ID} button {
                display: block;
                width: 100%;
                box-sizing: border-box;
                padding: 8px 10px;
                border: 0;
                border-radius: 8px;
                background: transparent;
                color: inherit;
                font: inherit;
                text-align: left;
                cursor: pointer;
            }

            #${AUTOCOMPLETE_ID} button.selected,
            #${AUTOCOMPLETE_ID} button:hover {
                background: rgba(255,255,255,0.14);
            }
        `;

        document.head.appendChild(style);
    }

    function createInput(id) {
        const input = document.createElement("input");

        input.id = id;

        input.placeholder =
            'Filter subscriptions, e.g. -channel:bizar title:"video essay"';

        input.autocomplete = "off";

        input.addEventListener("input", () => {
            currentQuery = input.value;
            syncInputs(id);
            updateAutocomplete(input);
        });

        input.addEventListener("keydown", event => {
            if (handleAutocompleteKeydown(event, input)) return;

            if (event.key === "Enter") {
                applyFilter();
                return;
            }

            if (
                event.key === " " &&
                !isInsideQuotes(input.value, input.selectionStart)
            ) {
                queueMicrotask(applyFilter);
            }
        });

        input.addEventListener("blur", () => {
            applyFilter();
            setTimeout(hideAutocomplete, 0);
        });
        input.addEventListener("focus", () => updateAutocomplete(input));
        input.addEventListener("click", () => updateAutocomplete(input));
        input.addEventListener("keyup", () => updateAutocomplete(input));

        return input;
    }

    function syncInputs(sourceId) {
        const inlineInput = document.getElementById(INLINE_INPUT_ID);
        const floatInput = document.getElementById(FLOAT_INPUT_ID);

        if (sourceId !== INLINE_INPUT_ID && inlineInput) {
            inlineInput.value = currentQuery;
        }

        if (sourceId !== FLOAT_INPUT_ID && floatInput) {
            floatInput.value = currentQuery;
        }
    }

    function findVideoGrid() {
        return (
            document.querySelector("ytd-rich-grid-renderer #contents") ||
            document.querySelector("ytd-section-list-renderer #contents")
        );
    }

    function addInlineBar() {
        if (document.getElementById(INLINE_BAR_ID)) return;

        const grid = findVideoGrid();

        if (!grid || !grid.parentElement) return;

        const bar = document.createElement("div");

        bar.id = INLINE_BAR_ID;

        const input = createInput(INLINE_INPUT_ID);

        bar.appendChild(input);

        grid.parentElement.insertBefore(bar, grid);
    }

    function addFloatingBar() {
        if (document.getElementById(FLOAT_BAR_ID)) return;

        const bar = document.createElement("div");

        bar.id = FLOAT_BAR_ID;

        const input = createInput(FLOAT_INPUT_ID);

        bar.appendChild(input);

        document.body.appendChild(bar);

        function setFloatingVisible(visible) {
            bar.classList.toggle("visible", visible);
            document.body.classList.toggle("yt-sub-filter-float-visible", visible);

            if (!visible && document.activeElement !== input) {
                hideAutocomplete();
            }
        }

        document.addEventListener("mousemove", event => {
            const nearTop = event.clientY < 120;

            if (nearTop) {
                setFloatingVisible(true);
            } else if (
                document.activeElement !== input
            ) {
                setFloatingVisible(false);
            }
        });

        input.addEventListener("focus", () => {
            setFloatingVisible(true);
        });

        input.addEventListener("blur", () => {
            setFloatingVisible(false);
        });
    }

    function addAutocomplete() {
        if (document.getElementById(AUTOCOMPLETE_ID)) return;

        const menu = document.createElement("div");

        menu.id = AUTOCOMPLETE_ID;

        document.body.appendChild(menu);
    }

    function getCurrentWord(input) {
        const cursorPosition = input.selectionStart ?? input.value.length;
        const textBeforeCursor = input.value.slice(0, cursorPosition);
        const tokenStart = textBeforeCursor.search(/\S+$/);

        if (tokenStart === -1) {
            return {
                start: cursorPosition,
                end: cursorPosition,
                value: ""
            };
        }

        const textAfterCursor = input.value.slice(cursorPosition);
        const nextSpace = textAfterCursor.search(/\s/);
        const tokenEnd =
            nextSpace === -1 ? input.value.length : cursorPosition + nextSpace;

        return {
            start: tokenStart,
            end: tokenEnd,
            value: input.value.slice(tokenStart, tokenEnd)
        };
    }

    function getAutocompleteOptions(input) {
        const word = getCurrentWord(input);
        const negative = word.value.startsWith("-");
        const typed = negative ? word.value.slice(1) : word.value;

        if (typed.includes(":")) {
            return getFieldValueOptions(word, negative);
        }

        return FIELD_TOKENS
            .filter(field => field.startsWith(typed.toLowerCase()))
            .map(field => ({
                label: `${negative ? "-" : ""}${field}:`,
                value: `${negative ? "-" : ""}${field}:`
            }));
    }

    function getFieldValueOptions(word, negative) {
        const token = negative ? word.value.slice(1) : word.value;
        const colonIndex = token.indexOf(":");
        const field = token.slice(0, colonIndex).toLowerCase();
        const typedValue = token.slice(colonIndex + 1).replace(/^"/, "");

        if (!["channel", "title", "text"].includes(field)) return [];

        const prefix = `${negative ? "-" : ""}${field}:`;
        const typedLower = typedValue.toLowerCase();
        const knownValues = getKnownFieldValues(field);
        const options = knownValues
            .filter(value => value.toLowerCase().startsWith(typedLower))
            .map(value => ({
                label: `${prefix}${formatAutocompleteValue(value)}`,
                value: `${prefix}${formatAutocompleteValue(value)}`
            }));

        if (typedValue && !typedValue.endsWith("*")) {
            options.push({
                label: `${prefix}${typedValue}*`,
                value: `${prefix}${typedValue}*`
            });
        }

        return options;
    }

    function getKnownFieldValues(field) {
        const values = getVideos()
            .map(video => getRawVideoData(video)[field])
            .filter(Boolean);

        return [...new Set(values)].sort((first, second) =>
            first.localeCompare(second)
        );
    }

    function formatAutocompleteValue(value) {
        return /\s/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
    }

    function updateAutocomplete(input) {
        const menu = document.getElementById(AUTOCOMPLETE_ID);

        if (!menu || document.activeElement !== input) return;

        const options = getAutocompleteOptions(input);

        if (!options.length) {
            hideAutocomplete();
            return;
        }

        autocompleteIndex = Math.min(autocompleteIndex, options.length - 1);

        menu.textContent = "";

        for (const [index, option] of options.entries()) {
            const button = document.createElement("button");

            button.type = "button";
            button.textContent = option.label;
            button.classList.toggle("selected", index === autocompleteIndex);

            button.addEventListener("mousedown", event => {
                event.preventDefault();
                insertAutocompleteOption(input, option.value);
            });

            menu.appendChild(button);
        }

        const rect = input.getBoundingClientRect();

        menu.style.left = `${rect.left}px`;
        menu.style.top = `${rect.bottom + 6}px`;
        menu.style.width = `${rect.width}px`;
        menu.classList.add("visible");
    }

    function hideAutocomplete() {
        const menu = document.getElementById(AUTOCOMPLETE_ID);

        if (menu) {
            menu.classList.remove("visible");
        }
    }

    function insertAutocompleteOption(input, option) {
        const word = getCurrentWord(input);
        const nextValue =
            input.value.slice(0, word.start) +
            option +
            input.value.slice(word.end);
        const nextCursorPosition = word.start + option.length;

        input.value = nextValue;
        input.setSelectionRange(nextCursorPosition, nextCursorPosition);
        currentQuery = input.value;
        syncInputs(input.id);
        hideAutocomplete();
        input.focus();
    }

    function handleAutocompleteKeydown(event, input) {
        const menu = document.getElementById(AUTOCOMPLETE_ID);

        if (!menu?.classList.contains("visible")) return false;

        const options = getAutocompleteOptions(input);

        if (!options.length) return false;

        if (event.key === "ArrowDown") {
            event.preventDefault();
            autocompleteIndex = (autocompleteIndex + 1) % options.length;
            updateAutocomplete(input);
            return true;
        }

        if (event.key === "ArrowUp") {
            event.preventDefault();
            autocompleteIndex =
                (autocompleteIndex - 1 + options.length) % options.length;
            updateAutocomplete(input);
            return true;
        }

        if (event.key === "Tab") {
            event.preventDefault();
            insertAutocompleteOption(input, options[autocompleteIndex].value);
            return true;
        }

        if (event.key === "Escape") {
            hideAutocomplete();
            return true;
        }

        return false;
    }

    function isInsideQuotes(text, cursorPosition) {
        const beforeCursor = text.slice(0, cursorPosition);

        const quoteCount = (beforeCursor.match(/"/g) || []).length;

        return quoteCount % 2 === 1;
    }

    function getVideos() {
        return [
            ...document.querySelectorAll(
                "ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-video-renderer"
            )
        ];
    }

    function getRawVideoData(video) {
        const title =
            video.querySelector(".ytLockupMetadataViewModelTitle")?.textContent?.trim() ||
            video.querySelector("#video-title")?.textContent?.trim() ||
            video.querySelector("a#video-title-link")?.textContent?.trim() ||
            video.querySelector("a#video-title")?.getAttribute("title")?.trim() ||
            video.querySelector("a#video-title-link")?.getAttribute("title")?.trim() ||
            video.querySelector("[aria-label]")?.getAttribute("aria-label")?.trim() ||
            "";

        const channel =
            video.querySelector("ytd-channel-name a")?.textContent?.trim() ||
            video.querySelector("#channel-name a")?.textContent?.trim() ||
            video.querySelector("a[href^='/@']")?.textContent?.trim() ||
            video.querySelector("a[href^='/channel/']")?.textContent?.trim() ||
            video.querySelector("ytd-channel-name")?.textContent?.trim() ||
            video.querySelector("#channel-name")?.textContent?.trim() ||
            "";

        const text = video.textContent || "";

        return {
            title,
            channel,
            text
        };
    }

    function getVideoData(video) {
        const data = getRawVideoData(video);

        return {
            title: data.title.toLowerCase(),
            channel: data.channel.toLowerCase(),
            text: data.text.toLowerCase()
        };
    }

    function parseQuery(query) {
        const tokens = [];
        const regex = /(-?)(?:(\w+):)?(?:"([^"]*)"|(\S+))/g;

        let match;

        while ((match = regex.exec(query)) !== null) {
            const negative = match[1] === "-";
            const field = match[2] ? match[2].toLowerCase() : "text";
            const value = (match[3] ?? match[4] ?? "").toLowerCase();

            if (value) {
                tokens.push({
                    negative,
                    field,
                    value
                });
            }
        }

        return tokens;
    }

    function matches(videoData, rules) {
        for (const rule of rules) {
            const fieldText =
                videoData[rule.field] ?? videoData.text;

            const found = rule.value.endsWith("*")
                ? fieldText.startsWith(rule.value.slice(0, -1))
                : fieldText.includes(rule.value);

            if (rule.negative && found) return false;

            if (!rule.negative && !found) return false;
        }

        return true;
    }

    function applyFilter() {
        const rules = parseQuery(currentQuery);

        for (const video of getVideos()) {
            const data = getVideoData(video);

            video.style.display =
                matches(data, rules) ? "" : "none";
        }
    }

    function scheduleApplyFilter() {
        if (applyFilterQueued) return;

        applyFilterQueued = true;

        queueMicrotask(() => {
            applyFilterQueued = false;
            applyFilter();
        });
    }

    function isVideoNode(node) {
        if (!(node instanceof Element)) return false;

        return (
            node.matches(
                "ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-video-renderer"
            ) ||
            node.querySelector(
                "ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-video-renderer"
            )
        );
    }

    function handleDomChanges(mutations) {
        let addedVideos = false;

        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (isVideoNode(node)) {
                    addedVideos = true;
                    break;
                }
            }

            if (addedVideos) break;
        }

        init();

        if (addedVideos && currentQuery.trim()) {
            scheduleApplyFilter();
        }
    }

    function init() {
        addStyles();
        addInlineBar();
        addFloatingBar();
        addAutocomplete();
    }

    const observer = new MutationObserver(handleDomChanges);

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    init();
})();