Greasy Fork 还支持 简体中文。

YouTube Save to playlist incremental search

Adds search + A–Z sorting + larger popup to the "Save to playlist" dialog. Lets you quickly filter your playlists, and keeps the popup usable without closing on input click. Updated for October 2025 layout.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         YouTube Save to playlist incremental search
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Adds search + A–Z sorting + larger popup to the "Save to playlist" dialog. Lets you quickly filter your playlists, and keeps the popup usable without closing on input click. Updated for October 2025 layout.
// @author       Jaq Drako
// @license      MIT
// @match        *://www.youtube.com/*
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js
// ==/UserScript==

(function () {
    'use strict';

    const $ = window.$;
    if (!$) {
        console.warn("[YT Playlist Filter] jQuery missing");
        return;
    }

    let originalOrderCache = null;
    let globalStyleInjected = false;

    function injectGlobalStyleOnce() {
        if (globalStyleInjected) return;
        globalStyleInjected = true;

        const css = `
            tp-yt-iron-dropdown #contentWrapper,
            tp-yt-iron-dropdown .ytContextualSheetLayoutContentContainer,
            tp-yt-iron-dropdown {
                max-height: 600px !important;
                height: 600px !important;
                overflow-y: auto !important;
            }
        `;

        const styleTag = document.createElement("style");
        styleTag.setAttribute("id", "ytPlaylistToolsGlobalStyle");
        styleTag.textContent = css;
        document.head.appendChild(styleTag);
    }

    function findSaveDialog() {
        const candidates = $("tp-yt-iron-dropdown:visible");

        for (let i = 0; i < candidates.length; i++) {
            const dlg = $(candidates[i]);
            const listCount = dlg.find("yt-list-view-model.ytListViewModelHost[role='list']").length;

            if (listCount > 0) {
                return dlg;
            }
        }

        return null;
    }

    function swallowEventsPreventClose($el) {
        const stopper = function (e) {
            e.stopPropagation();
            e.stopImmediatePropagation();
        };
        $el.on("mousedown click mouseup touchstart touchend", stopper);
    }

    function enlargeDialogHeight(dialogRoot) {
        dialogRoot.css({
            "max-height": "600px",
            "height": "600px"
        });

        dialogRoot.find("#contentWrapper").css({
            "max-height": "600px",
            "height": "600px",
            "overflow-y": "auto"
        });

        dialogRoot.find(".ytContextualSheetLayoutContentContainer").css({
            "max-height": "600px",
            "height": "600px",
            "overflow-y": "auto"
        });
    }

    function getPlaylistRows(dialogRoot) {
        const contentWrapper = dialogRoot.find("#contentWrapper").first();
        const listHost = contentWrapper
            .find("yt-list-view-model.ytListViewModelHost[role='list']")
            .first();

        const rows = listHost
            .find("toggleable-list-item-view-model.toggleableListItemViewModelHost");

        return { listHost, rows };
    }

    function getRowName($row) {
        const selectors = [
            ".yt-list-item-view-model__title",
            ".yt-core-attributed-string",
            "#label",
            "[id='label']",
            "[class*='title']",
            "[class*='Title']",
            "[class*='label']",
            "[class*='Label']",
            "span",
            "yt-formatted-string"
        ];

        for (const selector of selectors) {
            const $el = $row.find(selector).filter(function () {
                const text = ($(this).text() || "").replace(/\s+/g, " ").trim();
                return text.length > 0;
            }).first();

            if ($el.length > 0) {
                const text = ($el.text() || "").replace(/\s+/g, " ").trim();
                if (text) {
                    return text;
                }
            }
        }

        const fallbackText = ($row.text() || "")
            .replace(/\s+/g, " ")
            .trim();

        return fallbackText || "";
    }

    function filterPlaylists(dialogRoot) {
        const contentWrapper = dialogRoot.find("#contentWrapper").first();
        const termRaw = contentWrapper.find("#ytPlaylistSearch").val() || "";
        const term = termRaw.trim().toLowerCase();

        const { rows } = getPlaylistRows(dialogRoot);

        rows.each(function () {
            const $row = $(this);
            const name = getRowName($row).toLowerCase();

            if (!name) {
                $row.show();
                return;
            }

            if (!term || name.indexOf(term) !== -1) {
                $row.show();
            } else {
                $row.hide();
            }
        });
    }

    function sortPlaylists(dialogRoot, mode) {
        const { listHost, rows } = getPlaylistRows(dialogRoot);

        if (!originalOrderCache) {
            originalOrderCache = rows.toArray();
        }

        let newOrder;
        if (mode === "az" || mode === "za") {
            newOrder = rows.toArray().sort(function (a, b) {
                const nameA = getRowName($(a)).toLowerCase();
                const nameB = getRowName($(b)).toLowerCase();

                if (nameA < nameB) return (mode === "az") ? -1 : 1;
                if (nameA > nameB) return (mode === "az") ? 1 : -1;
                return 0;
            });
        } else {
            newOrder = originalOrderCache.slice();
        }

        listHost.empty();
        newOrder.forEach(function (node) {
            listHost.append(node);
        });
    }

    function ensureSearchAndSortUI(dialogRoot) {
        const contentWrapper = dialogRoot.find("#contentWrapper").first();
        if (!contentWrapper.length) return;

        const headerContainer = contentWrapper.find(".ytContextualSheetLayoutHeaderContainer").first();
        if (!headerContainer.length) return;

        if (contentWrapper.find("#ytPlaylistToolsWrapper").length > 0) return;

        const toolsHtml = [
            "<div id='ytPlaylistToolsWrapper'",
            "     style='box-sizing:border-box;padding:8px 16px 0 16px;",
            "            display:flex;flex-wrap:wrap;row-gap:8px;column-gap:12px;",
            "            align-items:flex-start;align-content:flex-start;'>",

            "  <div style='display:flex;flex-direction:row;align-items:center;gap:8px;flex:1;min-width:200px;'>",
            "    <label for='ytPlaylistSearch'",
            "           style='font-size:12px;font-weight:500;white-space:nowrap;color:var(--yt-spec-text-primary,#fff);'>Search:</label>",
            "    <input id='ytPlaylistSearch' type='search' placeholder='filter playlists...'",
            "           style='flex:1;font-size:12px;line-height:16px;padding:4px 6px;",
            "                  color:var(--yt-spec-text-primary,#fff);",
            "                  background-color:transparent;",
            "                  border:1px solid var(--yt-spec-text-secondary,#888);",
            "                  border-radius:4px;outline:none;'/>",
            "  </div>",

            "  <div style='display:flex;flex-direction:row;align-items:center;gap:6px;'>",
            "    <label for='ytPlaylistSort'",
            "           style='font-size:12px;font-weight:500;white-space:nowrap;color:var(--yt-spec-text-primary,#fff);'>Sort:</label>",
            "    <select id='ytPlaylistSort'",
            "            style='font-size:12px;line-height:16px;padding:4px 6px;",
            "                   background-color:#222; color:#fff;",
            "                   border:1px solid var(--yt-spec-text-secondary,#888);",
            "                   border-radius:4px;outline:none;'>",
            "      <option value='orig'>Original</option>",
            "      <option value='az'>A–Z</option>",
            "      <option value='za'>Z–A</option>",
            "    </select>",
            "  </div>",

            "</div>"
        ].join("");

        const $tools = $(toolsHtml).insertAfter(headerContainer);

        const $search = $tools.find("#ytPlaylistSearch");
        const $sort = $tools.find("#ytPlaylistSort");

        swallowEventsPreventClose($tools);
        swallowEventsPreventClose($search);
        swallowEventsPreventClose($sort);

        $search.on("input search", function () {
            filterPlaylists(dialogRoot);
        });

        $sort.on("change", function () {
            const mode = $(this).val();
            sortPlaylists(dialogRoot, mode);
            filterPlaylists(dialogRoot);
        });
    }

    function attachCloseHandler(dialogRoot) {
        if (dialogRoot.data("ytPlaylistFilterObserverAttached")) return;

        dialogRoot.data("ytPlaylistFilterObserverAttached", true);

        const observer = new MutationObserver(function () {
            if (!document.contains(dialogRoot[0])) {
                originalOrderCache = null;
                startPolling();
            }
        });

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

    let pollHandle = null;

    function pollStep() {
        const dlg = findSaveDialog();
        if (!dlg) return;

        stopPolling();

        injectGlobalStyleOnce();
        enlargeDialogHeight(dlg);
        ensureSearchAndSortUI(dlg);
        filterPlaylists(dlg);
        attachCloseHandler(dlg);
    }

    function startPolling() {
        stopPolling();
        pollHandle = setInterval(pollStep, 200);
    }

    function stopPolling() {
        if (pollHandle) {
            clearInterval(pollHandle);
            pollHandle = null;
        }
    }

    startPolling();
})();