YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

// ==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);
})();