YouTube Subscriptions Filter

Filter YouTube subscriptions using simple Google-style syntax

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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