// ==UserScript==
// @name YouTube Sub Feed Filter 2
// @version 0.1
// @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://greasyfork.org/scripts/446506-tree-frame-2/code/Tree%20Frame%202.js?version=1061073
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// ==/UserScript==
//TODO Include filters for badges (Verified, Official Artist Channel, ...)
// Also view counts, video lengths, ...
// User config
const LONG_PRESS_TIME = 400;
const REGEXP_FLAGS = 'i';
// Dev config
const VIDEO_TYPE_IDS = {
'GROUPS': {
'ALL': 'All',
'STREAMS': 'Streams',
'PREMIERS': 'Premiers',
'NONE': 'None'
},
'INDIVIDUALS': {
'STREAMS_SCHEDULED': 'Scheduled Streams',
'STREAMS_LIVE': 'Live Streams',
'STREAMS_FINISHED': 'Finished Streams',
'PREMIERS_SCHEDULED': 'Scheduled Premiers',
'PREMIERS_LIVE': 'Live Premiers',
'SHORTS': 'Shorts',
'NORMAL': 'Basic Videos'
}
};
const FRAME_STYLE = {
'OUTER': {'zIndex': 10000},
'INNER': {
'headBase': '#ff0000',
'headButtonExit': '#000000',
'borderHead': '#ffffff',
'nodeBase': ['#222222', '#111111'],
'borderTooltip': '#570000'
}
};
const TITLE = 'YouTube Sub Feed Filter';
const KEY_TREE = 'YTSFF_TREE';
const KEY_IS_ACTIVE = 'YTSFF_IS_ACTIVE';
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.PREMIERS:
register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED);
register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE);
break;
default:
register(value);
}
}
return registry;
}
const DEFAULT_TREE = (() => {
const regexPredicate = (value) => {
try {
RegExp(value);
} catch (e) {
return 'Value must be a valid regular expression.';
}
return true;
};
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': Object.values(VIDEO_TYPE_IDS.GROUPS).concat(Object.values(VIDEO_TYPE_IDS.INDIVIDUALS))
}],
'seed': {
'value': VIDEO_TYPE_IDS.GROUPS.NONE,
'predicate': Object.values(VIDEO_TYPE_IDS.GROUPS).concat(Object.values(VIDEO_TYPE_IDS.INDIVIDUALS))
},
'childPredicate': (children) => {
try {
getVideoTypes(children);
} catch ({message}) {
return message;
}
return true;
}
}
]
}
},
// {
// 'label': 'Options',
// 'children': [
// {
// // <div id="progress" class="style-scope ytd-thumbnail-overlay-resume-playback-renderer" style="width: 45%;"></div>
// 'label': 'Watched Cutoff (%)',
// 'value': 100,
// 'predicate': (value) => value > 0 ? true : 'Value must be greater than 0'
// }
// ]
// }
]
};
})();
// Video element helpers
function getAllSections() {
return [...document
.querySelector('.ytd-page-manager[page-subtype="subscriptions"]')
.querySelectorAll('ytd-item-section-renderer')
];
}
function getAllVideos(section) {
return [...section.querySelectorAll('ytd-grid-video-renderer')];
}
function firstWordEquals(element, word) {
return element.innerText.split(' ')[0] === word;
}
function getVideoBadges(video) {
const container = video.querySelector('#video-badges');
return container ? container.querySelectorAll('.badge') : [];
}
function getMetadataLine(video) {
return video.querySelector('#metadata-line');
}
// Video hiding predicates
class SectionSplitter {
static splitters = {
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED]: (video) => {
const [schedule] = getMetadataLine(video).children;
return firstWordEquals(schedule, 'Scheduled');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE]: (video) => {
for (const badge of getVideoBadges(video)) {
if (firstWordEquals(badge.querySelector('span.ytd-badge-supported-renderer'), 'LIVE')) {
return true;
}
}
return false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED]: (video) => {
const metaDataLine = getMetadataLine(video);
return metaDataLine.children.length > 1 && firstWordEquals(metaDataLine.children[1], 'Streamed');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED]: (video) => {
const [schedule] = getMetadataLine(video).children;
return firstWordEquals(schedule, 'Premieres');
},
[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE]: (video) => {
for (const badge of getVideoBadges(video)) {
const text = badge.querySelector('span.ytd-badge-supported-renderer');
if (firstWordEquals(text, 'PREMIERING') || firstWordEquals(text, 'PREMIERE')) {
return true;
}
}
return false;
},
[VIDEO_TYPE_IDS.INDIVIDUALS.SHORTS]: (video) => {
return new Promise(async (resolve) => {
let icon = video.querySelector('[overlay-style]');
// Stop searching if it gets a live badge
const predicate = () => getVideoBadges(video).length || (icon && icon.getAttribute('overlay-style'));
if (!predicate()) {
await new Promise((resolve) => {
const observer = new MutationObserver(() => {
icon = video.querySelector('[overlay-style]');
if (predicate()) {
observer.disconnect();
resolve();
}
});
observer.observe(video, {
'childList': true,
'subtree': true,
'attributes': true
});
});
}
resolve(icon && icon.getAttribute('overlay-style') === 'SHORTS');
});
},
[VIDEO_TYPE_IDS.INDIVIDUALS.NORMAL]: (video) => {
const [, {innerText}] = getMetadataLine(video).children;
return new RegExp('^\\d+ .+ ago$').test(innerText);
}
};
hideables = [];
promises = [];
constructor(section) {
this.videos = getAllVideos(section);
}
addHideables(channelRegex, titleRegex, videoType) {
const predicate = SectionSplitter.splitters[videoType];
const promises = [];
for (const video of this.videos) {
if (
channelRegex.test(video.querySelector('a.yt-formatted-string').innerText) &&
titleRegex.test(video.querySelector('a#video-title').innerText)
) {
promises.push(new Promise(async (resolve) => {
if (await predicate(video)) {
this.hideables.push(video);
}
resolve();
}));
}
}
this.promises.push(Promise.all(promises));
}
}
// Hider functions
function hideSection(section, doHide = true) {
if (section.matches(':first-child')) {
const title = section.querySelector('#title');
const videoContainer = section.querySelector('#contents').querySelector('#contents');
if (doHide) {
title.style.display = 'none';
videoContainer.style.display = 'none';
section.style.borderBottom = 'none';
} else {
title.style.removeProperty('display');
videoContainer.style.removeProperty('display');
section.style.removeProperty('borderBottom');
}
} else {
if (doHide) {
section.style.display = 'none';
} else {
section.style.removeProperty('display');
}
}
}
function hideVideo(video, doHide = true) {
if (doHide) {
video.style.display = 'none';
} else {
video.style.removeProperty('display');
}
}
function getConfig([filters, options]) {
return {
'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)
}))
})(),
'options': {
// 'time': options.children[0].value
}
};
}
function hideFromSections(config, sections = getAllSections()) {
for (const section of sections) {
if (section.matches('ytd-continuation-item-renderer')) {
continue;
}
const splitter = new SectionSplitter(section);
// Separate the section's videos by hideability
for (const {channels, videos, types} of config.filters) {
for (const type of types) {
splitter.addHideables(channels, videos, type);
}
}
Promise.all(splitter.promises)
.then(() => {
// Hide hideable videos
for (const video of splitter.hideables) {
hideVideo(video);
}
});
}
}
async function hideFromMutations(mutations) {
const sections = [];
for (const {addedNodes} of mutations) {
for (const section of addedNodes) {
sections.push(section);
}
}
hideFromSections(getConfig(await getForest(KEY_TREE, DEFAULT_TREE)), sections);
}
function resetConfig() {
for (const section of getAllSections()) {
hideSection(section, false);
for (const video of getAllVideos(section)) {
hideVideo(video, false);
}
}
}
// Button
function getButtonDock() {
return document
.querySelector('ytd-browse[page-subtype="subscriptions"]')
.querySelector('#title-container')
.querySelector('#top-level-buttons-computed');
}
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 {
constructor(pageManager) {
this.pageManager = pageManager;
this.element = this.getNewButton();
this.element.addEventListener('mousedown', this.onMouseDown.bind(this));
GM.getValue(KEY_IS_ACTIVE, true).then((isActive) => {
this.isActive = isActive;
if (isActive) {
this.setButtonActive();
this.pageManager.start();
}
});
}
addToDOM(button = this.element) {
const {parentElement} = getButtonDock();
parentElement.appendChild(button);
}
getNewButton() {
const openerTemplate = getButtonDock().children[1];
const button = openerTemplate.cloneNode(false);
this.addToDOM(button);
button.innerHTML = openerTemplate.innerHTML;
button.querySelector('button').innerHTML = openerTemplate.querySelector('button').innerHTML;
button.querySelector('a').removeAttribute('href');
// TODO Build the svg via javascript
button.querySelector('yt-icon').innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" focusable="false" viewBox="-50 -50 400 400"><g><path 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"/><rect x="13.95" y="0" width="294" height="45"/></g></svg>`;
return button;
}
hide() {
this.element.style.display = 'none';
}
show() {
this.element.parentElement.appendChild(this.element);
this.element.style.removeProperty('display');
}
setButtonActive() {
if (this.isActive) {
this.element.classList.add('style-blue-text');
this.element.classList.remove('style-opacity');
} else {
this.element.classList.add('style-opacity');
this.element.classList.remove('style-blue-text');
}
}
toggleActive() {
this.isActive = !this.isActive;
this.setButtonActive();
GM.setValue(KEY_IS_ACTIVE, this.isActive);
if (this.isActive) {
this.pageManager.start();
} else {
this.pageManager.stop();
}
}
onLongClick() {
editForest(KEY_TREE, DEFAULT_TREE, TITLE, FRAME_STYLE.INNER, FRAME_STYLE.OUTER)
.then((forest) => {
if (this.isActive) {
resetConfig();
// Hide filtered videos
hideFromSections(getConfig(forest));
}
})
.catch((error) => {
console.error(error);
if (window.confirm(
'An error was thrown by Tree Frame; Your data may be corrupted.\n' +
'Error Message: ' + error + '\n\n' +
'Would you like to clear your saved configs?'
)) {
GM.deleteValue(KEY_TREE);
}
});
}
async onMouseDown(event) {
if (event.button === 0) {
new ClickHandler(this.element, this.toggleActive.bind(this), this.onLongClick.bind(this));
}
}
}
// Page load/navigation handler
class PageManager {
constructor() {
// Don't run in frames (e.g. stream chat frame)
if (window.parent !== window) {
return;
}
this.videoObserver = new MutationObserver(hideFromMutations);
const emitter = document.getElementById('page-manager');
const event = 'yt-action';
const onEvent = ({detail}) => {
if (detail.actionName === 'ytd-update-grid-state-action') {
this.onLoad();
emitter.removeEventListener(event, onEvent);
}
};
emitter.addEventListener(event, onEvent);
}
start() {
getForest(KEY_TREE, DEFAULT_TREE).then(forest => {
// Call hide function when new videos are loaded
this.videoObserver.observe(
document.querySelector('ytd-browse[page-subtype="subscriptions"]').querySelector('div#contents'),
{childList: true}
);
try {
hideFromSections(getConfig(forest));
} catch (e) {
debugger;
}
});
}
stop() {
this.videoObserver.disconnect();
resetConfig();
}
isSubPage() {
return new RegExp('^.*youtube.com/feed/subscriptions(\\?flow=1|\\?pbjreload=\\d+)?$').test(document.URL);
}
isGridView() {
return document.querySelector('ytd-expanded-shelf-contents-renderer') === null;
}
onLoad() {
// Allow configuration
if (this.isSubPage() && this.isGridView()) {
this.button = new Button(this);
this.button.show();
}
document.body.addEventListener('yt-navigate-finish', (function({detail}) {
this.onNavigate(detail);
}).bind(this));
document.body.addEventListener('popstate', (function({state}) {
this.onNavigate(state);
}).bind(this));
}
onNavigate({endpoint}) {
if (endpoint.browseEndpoint) {
const {params, browseId} = endpoint.browseEndpoint;
if ((params === 'MAE%3D' || (!params && this.isGridView())) && browseId === 'FEsubscriptions') {
if (!this.button) {
this.button = new Button(this);
}
this.button.show();
this.start();
} else {
if (this.button) {
this.button.hide();
}
this.videoObserver.disconnect();
}
}
}
}
// Main
new PageManager();