// ==UserScript==
// @name YouTube Sub Feed Filter 2
// @version 1.40
// @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/*
// @exclude *://www.youtube.com/embed/*
// @exclude *://youtube.com/embed/*
// @require https://update.greasyfork.org/scripts/446506/1424453/%24Config.js
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// ==/UserScript==
/* global $Config */
// 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 videoTypeOptions = 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,
options: videoTypeOptions,
},
],
seed: {
value: VIDEO_TYPE_IDS.GROUPS.NONE,
options: videoTypeOptions,
},
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],
options: 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],
options: 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],
options: CUTOFF_VALUES,
},
{value: 0},
],
},
},
],
},
{
label: 'Badges',
children: [
{
label: 'Verified',
value: BADGE_VALUES[1],
options: BADGE_VALUES,
},
{
label: 'Official Artist',
value: BADGE_VALUES[1],
options: BADGE_VALUES,
},
],
},
],
};
})(),
([filters, cutoffs, badges]) => ({
filters: (() => {
const getRegex = ({children}) => children.length === 0 ?
null :
new RegExp(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: '#c80000',
headButtonExit: '#000000',
borderHead: '#ffffff',
nodeBase: ['#222222', '#111111'],
borderTooltip: '#c80000',
},
{
zIndex: 10000,
scrollbarColor: 'initial',
},
);
const KEY_IS_ACTIVE = 'YTSFF_IS_ACTIVE';
// Video element helpers
function getSubPage() {
return document.querySelector('.ytd-page-manager[page-subtype="subscriptions"]');
}
function getAllVideos() {
const subPage = getSubPage();
return [...subPage.querySelectorAll('#primary > ytd-rich-grid-renderer > #contents > :not(:first-child):not(ytd-continuation-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 isShorts(video) {
return video.matches('[is-shorts] *');
}
function getMetadataLine(video) {
return video.querySelector(isShorts(video) ? '.ShortsLockupViewModelHostOutsideMetadata' : '#metadata-line');
}
function isScheduled(video) {
if (isShorts(video)) {
return false;
}
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]: isShorts,
[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') || child.matches('div[aria-label~=views]'),
);
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')) {
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')
|| video.querySelector('.ShortsLockupViewModelHostOutsideMetadataTitle')
).innerText;
for (const {'channels': channelRegex, 'videos': videoRegex, types} of filters) {
if ((!channelRegex || (channelName && channelRegex.test(channelName))) && (!videoRegex || 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);
}
},
};
})();
const showList = (() => {
const ATTRIBUTE = 'is-in-first-column';
const list = [];
const observers = [];
let rowLength;
let rowRemaining = 1;
let hasReverted = true;
function disconnectObservers() {
for (const observer of observers) {
observer.disconnect();
}
observers.length = 0;
}
function show(video, isFirst) {
const act = isFirst ?
() => video.setAttribute(ATTRIBUTE, true) :
() => video.removeAttribute(ATTRIBUTE);
act();
const observer = new MutationObserver(() => {
observer.disconnect();
act();
// Avoids observation cycle that I can't figure out the cause of
window.setTimeout(() => {
observer.observe(video, {attributeFilter: [ATTRIBUTE]});
}, 0);
});
observer.observe(video, {attributeFilter: [ATTRIBUTE]});
observers.push(observer);
}
return {
'add'(doAct, video) {
if (list.length === 0) {
rowLength = video.itemsPerRow ?? 3;
}
if (doAct) {
hasReverted = false;
}
const isFirst = --rowRemaining === 0;
if (isFirst) {
rowRemaining = rowLength;
}
list.push({video, isFirst, wasFirst: video.hasAttribute(ATTRIBUTE)});
if (doAct) {
show(video, isFirst);
}
},
'revert'(doErase) {
if (!hasReverted) {
hasReverted = true;
for (const {video, wasFirst} of list) {
show(video, wasFirst);
}
disconnectObservers();
}
if (doErase) {
list.length = 0;
rowRemaining = 1;
}
},
'ensure'() {
if (!hasReverted) {
return;
}
hasReverted = false;
for (const {video, isFirst} of list) {
show(video, isFirst);
}
},
'lineFeed'() {
rowRemaining = 1;
},
};
})();
async function hideVideo(doAct, element, config) {
if (element.tagName === 'YTD-RICH-ITEM-RENDERER') {
await loadVideo(element);
if (shouldHide(config, element)) {
hideList.add(doAct, element);
} else {
showList.add(doAct, element);
}
return;
}
let doHide = true;
for (const video of element.querySelectorAll('ytd-rich-item-renderer')) {
await loadVideo(video);
if (shouldHide(config, video)) {
hideList.add(doAct, video);
} else {
showList.add(doAct, video);
doHide = false;
}
}
if (doHide) {
hideList.add(doAct, element);
} else {
showList.lineFeed();
}
}
async function hideVideos(doAct, videos = getAllVideos()) {
const config = $config.get();
for (const video of videos) {
await Promise.all([
hideVideo(doAct, video, config),
// Allow the page to update visually before moving on
new Promise((resolve) => {
window.setTimeout(resolve, 0);
}),
]);
}
}
// Helpers
function hideFromMutations(isActive, mutations) {
const videos = [];
for (const {addedNodes} of mutations) {
for (const node of addedNodes) {
switch (node.tagName) {
case 'YTD-RICH-ITEM-RENDERER':
case 'YTD-RICH-SECTION-RENDERER':
videos.push(node);
}
}
}
hideVideos(isActive(), videos);
}
function resetConfig(fullReset = true) {
hideList.revert(fullReset);
showList.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');
}
// 🤷♀️
const policy = trustedTypes?.createPolicy('policy', {createHTML: (string) => string}) ?? {createHTML: (string) => string};
parentElement.appendChild(button);
button.innerHTML = policy.createHTML(openerTemplate.innerHTML);
button.querySelector('yt-button-shape').innerHTML = policy.createHTML(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},
);
hideVideos(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();
hideVideos(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();
showList.ensure();
} else {
hideList.revert(false);
showList.revert(false);
}
}
async onLongClick() {
await $config.edit();
resetConfig();
hideVideos(this.isActive);
}
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;
hideVideos(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-renderer ytd-rich-item-renderer ytd-rich-grid-media'),
);
};
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();
showList.revert();
}
}
document.body.addEventListener('yt-navigate-finish', onNavigate);
})();