// ==UserScript==
// @name YouTube Sub Feed Filter 2
// @version 1.17
// @description Filters your YouTube subscriptions feed.
// @author Callum Latham
// @namespace https://greasyfork.org/users/696211-ctl2
// @license MIT
// @match *://www.youtube.com/*
// @match *://youtube.com/*
// @require https://update.greasyfork.org/scripts/446506/1298241/%24Config.js
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// ==/UserScript==
// Don't run in frames (e.g. stream chat frame)
if (window.parent !== window) {
// noinspection JSAnnotator
return;
}
// User config
const LONG_PRESS_TIME = 400;
const REGEXP_FLAGS = 'i';
// Dev config
const VIDEO_TYPE_IDS = {
'GROUPS': {
'ALL': 'All',
'STREAMS': 'Streams',
'PREMIERES': 'Premieres',
'NONE': 'None',
},
'INDIVIDUALS': {
'STREAMS_SCHEDULED': 'Scheduled Streams',
'STREAMS_LIVE': 'Live Streams',
'STREAMS_FINISHED': 'Finished Streams',
'PREMIERES_SCHEDULED': 'Scheduled Premieres',
'PREMIERES_LIVE': 'Live Premieres',
'SHORTS': 'Shorts',
'FUNDRAISERS': 'Fundraisers',
'NORMAL': 'Basic Videos',
},
};
const CUTOFF_VALUES = [
'Minimum',
'Maximum',
];
const BADGE_VALUES = [
'Exclude',
'Include',
'Require',
];
const TITLE = 'YouTube Sub Feed Filter';
function getVideoTypes(children) {
const registry = new Set();
const register = (value) => {
if (registry.has(value)) {
throw new Error(`Overlap found at '${value}'.`);
}
registry.add(value);
};
for (const {value} of children) {
switch (value) {
case VIDEO_TYPE_IDS.GROUPS.ALL:
Object.values(VIDEO_TYPE_IDS.INDIVIDUALS).forEach(register);
break;
case VIDEO_TYPE_IDS.GROUPS.STREAMS:
register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED);
register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE);
register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED);
break;
case VIDEO_TYPE_IDS.GROUPS.PREMIERES:
register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_SCHEDULED);
register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_LIVE);
break;
default:
register(value);
}
}
return registry;
}
const $config = new $Config(
'YTSFF_TREE',
(() => {
const regexPredicate = (value) => {
try {
RegExp(value);
} catch (_) {
return 'Value must be a valid regular expression.';
}
return true;
};
const videoTypePredicate = Object.values({
...VIDEO_TYPE_IDS.GROUPS,
...VIDEO_TYPE_IDS.INDIVIDUALS,
});
return {
'children': [
{
'label': 'Filters',
'children': [],
'seed': {
'label': 'Filter Name',
'value': '',
'children': [
{
'label': 'Channel Regex',
'children': [],
'seed': {
'value': '^',
'predicate': regexPredicate,
},
},
{
'label': 'Video Regex',
'children': [],
'seed': {
'value': '^',
'predicate': regexPredicate,
},
},
{
'label': 'Video Types',
'children': [{
'value': VIDEO_TYPE_IDS.GROUPS.ALL,
'predicate': videoTypePredicate,
}],
'seed': {
'value': VIDEO_TYPE_IDS.GROUPS.NONE,
'predicate': videoTypePredicate,
},
'childPredicate': (children) => {
try {
getVideoTypes(children);
} catch ({message}) {
return message;
}
return true;
},
},
],
},
},
{
'label': 'Cutoffs',
'children': [
{
'label': 'Watched (%)',
'children': [],
'seed': {
'childPredicate': ([{'value': boundary}, {value}]) => {
if (boundary === CUTOFF_VALUES[0]) {
return value < 100 ? true : 'Minimum must be less than 100%';
}
return value > 0 ? true : 'Maximum must be greater than 0%';
},
'children': [
{
'value': CUTOFF_VALUES[1],
'predicate': CUTOFF_VALUES,
},
{
'value': 100,
},
],
},
},
{
'label': 'View Count',
'children': [],
'seed': {
'childPredicate': ([{'value': boundary}, {value}]) => {
if (boundary === CUTOFF_VALUES[1]) {
return value > 0 ? true : 'Maximum must be greater than 0';
}
return true;
},
'children': [
{
'value': CUTOFF_VALUES[0],
'predicate': CUTOFF_VALUES,
},
{
'value': 0,
'predicate': (value) => Math.floor(value) === value ? true : 'Value must be an integer',
},
],
},
},
{
'label': 'Duration (Minutes)',
'children': [],
'seed': {
'childPredicate': ([{'value': boundary}, {value}]) => {
if (boundary === CUTOFF_VALUES[1]) {
return value > 0 ? true : 'Maximum must be greater than 0';
}
return true;
},
'children': [
{
'value': CUTOFF_VALUES[0],
'predicate': CUTOFF_VALUES,
},
{
'value': 0,
},
],
},
},
],
},
{
'label': 'Badges',
'children': [
{
'label': 'Verified',
'value': BADGE_VALUES[1],
'predicate': BADGE_VALUES,
},
{
'label': 'Official Artist',
'value': BADGE_VALUES[1],
'predicate': BADGE_VALUES,
},
],
},
],
};
})(),
([filters, cutoffs, badges]) => ({
'filters': (() => {
const getRegex = ({children}) => new RegExp(children.length === 0 ? '' :
children.map(({value}) => `(${value})`).join('|'), REGEXP_FLAGS);
return filters.children.map(({'children': [channel, video, type]}) => ({
'channels': getRegex(channel),
'videos': getRegex(video),
'types': type.children.length === 0 ? Object.values(VIDEO_TYPE_IDS.INDIVIDUALS) : getVideoTypes(type.children),
}));
})(),
'cutoffs': cutoffs.children.map(({children}) => {
const boundaries = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
for (const {'children': [{'value': boundary}, {value}]} of children) {
boundaries[boundary === CUTOFF_VALUES[0] ? 0 : 1] = value;
}
return boundaries;
}),
'badges': badges.children.map(({value}) => BADGE_VALUES.indexOf(value)),
}),
TITLE,
{
'headBase': '#ff0000',
'headButtonExit': '#000000',
'borderHead': '#ffffff',
'nodeBase': ['#222222', '#111111'],
'borderTooltip': '#570000',
},
{'zIndex': 10000},
);
const KEY_IS_ACTIVE = 'YTSFF_IS_ACTIVE';
// Removing row styling
(() => {
const styleElement = document.createElement('style');
document.head.appendChild(styleElement);
const styleSheet = styleElement.sheet;
const rules = [
['ytd-rich-grid-row #contents.ytd-rich-grid-row', [
['display', 'contents'],
]],
['ytd-rich-grid-row', [
['display', 'contents'],
]],
];
for (let rule of rules) {
styleSheet.insertRule(`${rule[0]}{${rule[1].map(([property, value]) => `${property}:${value} !important;`).join('')}}`);
}
})();
// Video element helpers
function getSubPage() {
return document.querySelector('.ytd-page-manager[page-subtype="subscriptions"]');
}
function getAllRows() {
const subPage = getSubPage();
return subPage ? [...subPage.querySelectorAll('ytd-rich-grid-row')] : [];
}
function getAllSections() {
const subPage = getSubPage();
return subPage ? [...subPage.querySelectorAll('ytd-rich-section-renderer:not(:first-child)')] : [];
}
function getAllVideos(row) {
return [...row.querySelectorAll('ytd-rich-item-renderer')];
}
function firstWordEquals(element, word) {
return element.innerText.split(' ')[0] === word;
}
function getVideoBadges(video) {
return video.querySelectorAll('.video-badge');
}
function getChannelBadges(video) {
const container = video.querySelector('ytd-badge-supported-renderer.ytd-channel-name');
return container ? [...container.querySelectorAll('.badge')] : [];
}
function getMetadataLine(video) {
return video.querySelector('#metadata-line');
}
function isScheduled(video) {
return VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED](video) ||
VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_SCHEDULED](video);
}
function getUploadTimeNode(video) {
const children = [...getMetadataLine(video).children].filter((child) => child.matches('.inline-metadata-item'));
return children.length > 1 ? children[1] : null;
}
// Config testers
const VIDEO_PREDICATES = {
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED]: (video) => {
const metadataLine = getMetadataLine(video);
return firstWordEquals(metadataLine, 'Scheduled');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE]: (video) => {
for (const badge of getVideoBadges(video)) {
if (firstWordEquals(badge, 'LIVE')) {
return true;
}
}
return false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED]: (video) => {
const uploadTimeNode = getUploadTimeNode(video);
return uploadTimeNode && firstWordEquals(uploadTimeNode, 'Streamed');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_SCHEDULED]: (video) => {
const metadataLine = getMetadataLine(video);
return firstWordEquals(metadataLine, 'Premieres');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_LIVE]: (video) => {
for (const badge of getVideoBadges(video)) {
if (firstWordEquals(badge, 'PREMIERING') || firstWordEquals(badge, 'PREMIERE')) {
return true;
}
}
return false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.SHORTS]: (video) => {
return video.querySelector('ytd-rich-grid-slim-media')?.isShort ?? false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.NORMAL]: (video) => {
const uploadTimeNode = getUploadTimeNode(video);
return uploadTimeNode ? new RegExp('^\\d+ .+ ago$').test(uploadTimeNode.innerText) : false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.FUNDRAISERS]: (video) => {
for (const badge of getVideoBadges(video)) {
if (firstWordEquals(badge, 'Fundraiser')) {
return true;
}
}
return false;
},
};
const CUTOFF_GETTERS = [
// Watched %
(video) => {
const progressBar = video.querySelector('#progress');
if (!progressBar) {
return 0;
}
return Number.parseInt(progressBar.style.width.slice(0, -1));
},
// View count
(video) => {
if (isScheduled(video)) {
return 0;
}
const {innerText} = [...getMetadataLine(video).children].find((child) => child.matches('.inline-metadata-item'));
const [valueString] = innerText.split(' ');
const lastChar = valueString.slice(-1);
if (/\d/.test(lastChar)) {
return Number.parseInt(valueString);
}
const valueNumber = Number.parseFloat(valueString.slice(0, -1));
switch (lastChar) {
case 'B':
return valueNumber * 1000000000;
case 'M':
return valueNumber * 1000000;
case 'K':
return valueNumber * 1000;
}
return valueNumber;
},
// Duration (minutes)
(video) => {
const timeElement = video.querySelector('ytd-thumbnail-overlay-time-status-renderer');
let minutes = 0;
if (timeElement) {
const timeParts = timeElement.innerText.split(':').map((_) => Number.parseInt(_));
let timeValue = 1 / 60;
for (let i = timeParts.length - 1; i >= 0; --i) {
minutes += timeParts[i] * timeValue;
timeValue *= 60;
}
}
return Number.isNaN(minutes) ? 0 : minutes;
},
];
const BADGE_PREDICATES = [
// Verified
(video) => getChannelBadges(video)
.some((badge) => badge.classList.contains('badge-style-type-verified')),
// Official Artist
(video) => getChannelBadges(video)
.some((badge) => badge.classList.contains('badge-style-type-verified-artist')),
];
// Hider functions
function loadVideo(video) {
return new Promise((resolve) => {
const test = () => {
if (video.querySelector('#interaction.yt-icon-button')) {
observer.disconnect();
resolve();
}
};
const observer = new MutationObserver(test);
observer.observe(video, {
'childList': true,
'subtree': true,
'attributes': true,
'attributeOldValue': true,
});
test();
});
}
function shouldHide({filters, cutoffs, badges}, video) {
for (let i = 0; i < BADGE_PREDICATES.length; ++i) {
if (badges[i] !== 1 && Boolean(badges[i]) !== BADGE_PREDICATES[i](video)) {
return true;
}
}
for (let i = 0; i < CUTOFF_GETTERS.length; ++i) {
const [lowerBound, upperBound] = cutoffs[i];
const value = CUTOFF_GETTERS[i](video);
if (value < lowerBound || value > upperBound) {
return true;
}
}
const channelName = video.querySelector('ytd-channel-name#channel-name')?.innerText;
const videoName = video.querySelector('#video-title').innerText;
for (const {'channels': channelRegex, 'videos': videoRegex, types} of filters) {
if (
(!channelName || channelRegex.test(channelName)) &&
videoRegex.test(videoName)
) {
for (const type of types) {
if (VIDEO_PREDICATES[type](video)) {
return true;
}
}
}
}
return false;
}
const hideList = (() => {
const list = [];
let hasReverted = true;
function hide(element, doHide) {
element.hidden = false;
if (doHide) {
element.style.display = 'none';
} else {
element.style.removeProperty('display');
}
}
return {
'add'(doAct, element, doHide = true) {
if (doAct) {
hasReverted = false;
}
list.push({element, doHide, 'wasHidden': element.hidden});
if (doAct) {
hide(element, doHide);
}
},
'revert'(doErase) {
if (!hasReverted) {
hasReverted = true;
for (const {element, doHide, wasHidden} of list) {
hide(element, !doHide);
element.hidden = wasHidden;
}
}
if (doErase) {
list.length = 0;
}
},
'ensure'() {
if (!hasReverted) {
return;
}
hasReverted = false;
for (const {element, doHide} of list) {
hide(element, doHide);
}
},
};
})();
async function hideFromRows(config, doAct, groups = getAllRows()) {
for (const group of groups) {
const videos = getAllVideos(group);
// Process all videos in the row in parallel
await Promise.all(videos.map((video) => new Promise(async (resolve) => {
await loadVideo(video);
if (shouldHide(config, video)) {
hideList.add(doAct, video);
}
resolve();
})));
// Allow the page to update visually before moving on to the next row
await new Promise((resolve) => {
window.setTimeout(resolve, 0);
});
}
}
const hideFromSections = (() => {
return async (config, doAct, groups = getAllSections()) => {
for (const group of groups) {
const shownVideos = [];
const backupVideos = [];
for (const video of getAllVideos(group)) {
await loadVideo(video);
if (video.hidden) {
if (!shouldHide(config, video)) {
backupVideos.push(video);
}
} else {
shownVideos.push(video);
}
}
let lossCount = 0;
// Process all videos in the row in parallel
await Promise.all(shownVideos.map((video) => new Promise(async (resolve) => {
await loadVideo(video);
if (shouldHide(config, video)) {
hideList.add(doAct, video);
if (backupVideos.length > 0) {
hideList.add(doAct, backupVideos.shift(), false);
} else {
lossCount++;
}
}
resolve();
})));
if (lossCount >= shownVideos.length) {
hideList.add(doAct, group);
}
// Allow the page to update visually before moving on to the next row
await new Promise((resolve) => {
window.setTimeout(resolve, 0);
});
}
};
})();
function hideAll(doAct = true, rows, sections, config = $config.get()) {
return Promise.all([
hideFromRows(config, doAct, rows),
hideFromSections(config, doAct, sections),
]);
}
// Helpers
async function hideFromMutations(isActive, mutations) {
const rows = [];
const sections = [];
for (const {addedNodes} of mutations) {
for (const node of addedNodes) {
switch (node.tagName) {
case 'YTD-RICH-GRID-ROW':
rows.push(node);
break;
case 'YTD-RICH-SECTION-RENDERER':
sections.push(node);
}
}
}
hideAll(isActive(), rows, sections);
}
function resetConfig(fullReset = true) {
hideList.revert(fullReset);
}
function getButtonDock() {
return document
.querySelector('ytd-browse[page-subtype="subscriptions"]')
.querySelector('#contents')
.querySelector('#title-container')
.querySelector('#top-level-buttons-computed');
}
// Button
class ClickHandler {
constructor(button, onShortClick, onLongClick) {
this.onShortClick = (function() {
onShortClick();
window.clearTimeout(this.longClickTimeout);
window.removeEventListener('mouseup', this.onShortClick);
}).bind(this);
this.onLongClick = (function() {
window.removeEventListener('mouseup', this.onShortClick);
onLongClick();
}).bind(this);
this.longClickTimeout = window.setTimeout(this.onLongClick, LONG_PRESS_TIME);
window.addEventListener('mouseup', this.onShortClick);
}
}
class Button {
wasActive;
isActive = false;
isDormant = false;
constructor() {
this.element = (() => {
const getSVG = () => {
const svgNamespace = 'http://www.w3.org/2000/svg';
const bottom = document.createElementNS(svgNamespace, 'path');
bottom.setAttribute('d', 'M128.25,175.6c1.7,1.8,2.7,4.1,2.7,6.6v139.7l60-51.3v-88.4c0-2.5,1-4.8,2.7-6.6L295.15,65H26.75L128.25,175.6z');
const top = document.createElementNS(svgNamespace, 'rect');
top.setAttribute('x', '13.95');
top.setAttribute('width', '294');
top.setAttribute('height', '45');
const g = document.createElementNS(svgNamespace, 'g');
g.appendChild(bottom);
g.appendChild(top);
const svg = document.createElementNS(svgNamespace, 'svg');
svg.setAttribute('viewBox', '-50 -50 400 400');
svg.setAttribute('focusable', 'false');
svg.appendChild(g);
return svg;
};
const getNewButton = () => {
const {parentElement, 'children': [, openerTemplate]} = getButtonDock();
const button = openerTemplate.cloneNode(false);
if (openerTemplate.innerText) {
throw new Error('too early');
}
parentElement.appendChild(button);
button.innerHTML = openerTemplate.innerHTML;
button.querySelector('yt-button-shape').innerHTML = openerTemplate.querySelector('yt-button-shape').innerHTML;
button.querySelector('a').removeAttribute('href');
button.querySelector('yt-icon').appendChild(getSVG());
button.querySelector('tp-yt-paper-tooltip').remove();
return button;
};
return getNewButton();
})();
this.element.addEventListener('mousedown', this.onMouseDown.bind(this));
GM.getValue(KEY_IS_ACTIVE, true).then((isActive) => {
this.isActive = isActive;
this.update();
const videoObserver = new MutationObserver(hideFromMutations.bind(null, () => this.isActive));
videoObserver.observe(
document.querySelector('ytd-browse[page-subtype="subscriptions"]').querySelector('div#contents'),
{childList: true},
);
hideAll(isActive);
});
let resizeCount = 0;
window.addEventListener('resize', () => {
const resizeId = ++resizeCount;
this.forceInactive();
const listener = ({detail}) => {
// column size changed
if (detail.actionName === 'yt-window-resized') {
window.setTimeout(() => {
if (resizeId !== resizeCount) {
return;
}
this.forceInactive(false);
// Don't bother re-running filters if the sub page isn't shown
if (this.isDormant) {
return;
}
resetConfig();
hideAll(this.isActive);
}, 1000);
document.body.removeEventListener('yt-action', listener);
}
};
document.body.addEventListener('yt-action', listener);
});
}
forceInactive(doForce = true) {
if (doForce) {
// if wasActive isn't undefined, forceInactive was already called
if (this.wasActive === undefined) {
// Saves a GM.getValue call later
this.wasActive = this.isActive;
this.isActive = false;
}
} else {
this.isActive = this.wasActive;
this.wasActive = undefined;
}
}
update() {
if (this.isActive) {
this.setButtonActive();
}
}
setButtonActive() {
if (this.isActive) {
this.element.querySelector('svg').style.setProperty('fill', 'var(--yt-spec-call-to-action)');
} else {
this.element.querySelector('svg').style.setProperty('fill', 'currentcolor');
}
}
toggleActive() {
this.isActive = !this.isActive;
this.setButtonActive();
GM.setValue(KEY_IS_ACTIVE, this.isActive);
if (this.isActive) {
hideList.ensure();
} else {
hideList.revert(false);
}
}
async onLongClick() {
await $config.edit();
resetConfig();
hideAll(this.isActive);
}
async onMouseDown(event) {
if (event.button === 0) {
new ClickHandler(this.element, this.toggleActive.bind(this), this.onLongClick.bind(this));
}
}
}
// Main
(() => {
let button;
const loadButton = async () => {
if (button) {
button.isDormant = false;
hideAll(button.isActive);
return;
}
try {
await $config.ready;
} catch (error) {
if (!$config.reset) {
throw error;
}
if (!window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
return;
}
$config.reset();
}
try {
getButtonDock();
button = new Button();
} catch (e) {
const emitter = document.getElementById('page-manager');
const bound = () => {
loadButton();
emitter.removeEventListener('yt-action', bound);
};
emitter.addEventListener('yt-action', bound);
}
};
const isGridView = () => {
return Boolean(
document.querySelector('ytd-browse[page-subtype="subscriptions"]:not([hidden])') &&
document.querySelector('ytd-browse > ytd-two-column-browse-results-renderer ytd-rich-grid-row ytd-rich-item-renderer ytd-rich-grid-media'),
);
};
async function onNavigate({detail}) {
if (detail.endpoint.browseEndpoint) {
const {params, browseId} = detail.endpoint.browseEndpoint;
// Handle navigation to the sub feed
if ((params === 'MAE%3D' || (!params && (!button || isGridView()))) && browseId === 'FEsubscriptions') {
const emitter = document.querySelector('ytd-app');
const event = 'yt-action';
if (button || isGridView()) {
loadButton();
} else {
const listener = ({detail}) => {
if (detail.actionName === 'ytd-update-grid-state-action') {
if (isGridView()) {
loadButton();
}
emitter.removeEventListener(event, listener);
}
};
emitter.addEventListener(event, listener);
}
return;
}
}
// Handle navigation away from the sub feed
if (button) {
button.isDormant = true;
hideList.revert();
}
}
document.body.addEventListener('yt-navigate-finish', onNavigate);
})();