Filter YouTube subscriptions using simple Google-style syntax
// ==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();
})();