Youtube Sort & Filter Playlists When Saving Video

When saving a video to a playlist, 1) add a button to sort the list alphabetically and 2) add a filter textbox to filter the list by title

// ==UserScript==
// @name         Youtube Sort & Filter Playlists When Saving Video
// @version      2024.04.13.1
// @namespace    https://gist.github.com/CaptainJack0404
// @description  When saving a video to a playlist, 1) add a button to sort the list alphabetically and 2) add a filter textbox to filter the list by title
// @author       CaptainJack0404
// @noframes
// @match        https://www.youtube.com/*
// @match        https://youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        none
// ==/UserScript==

// When saving a video to a playlist:
// 1) add a button to sort the list alphabetically
// 2) add a filter textbox to filter the list by title

(function () {
	'use strict';

	const selectorApp = 'ytd-app';
	const selectorPopupContainer = 'ytd-popup-container';
	const selectorPopupDialog = `tp-yt-paper-dialog`;

	// Handles filter textbox input (immediate filter on each keyup)
	function keyupSearch(e = null) {
		var arr = [];
		var elPlayList = document.querySelector("#playlists.ytd-add-to-playlist-renderer");
		elPlayList.style.display = "flex";
		elPlayList.style.flexDirection = "column";

		// Get each Playlist element
		document.querySelectorAll("#playlists yt-formatted-string").forEach(function (item) {
			arr.push(item.innerHTML);
		});

		// Filter the junk out of the playlist element array
		let filtered = arr.filter(function (item) {
			return (item != '<!--css-build:shady-->' && (e == null || item.toLowerCase().includes((e.target.value).toLowerCase())));
		});

		document.querySelectorAll("#playlists.ytd-add-to-playlist-renderer #label").forEach(function (el1, index) {
			// Get main element wrapper for the current playlist and hide it
			el1.closest("ytd-playlist-add-to-option-renderer.ytd-add-to-playlist-renderer").style.display = "none";

			// Check to see if playlist is in the filtered array and if so then show it
			filtered.forEach(function (item) {
				if (el1.innerHTML == item) {
					// Get main element wrapper for the current playlist and show it
					el1.closest("ytd-playlist-add-to-option-renderer.ytd-add-to-playlist-renderer").style.display = "block";
				}
			});
		});

		document.querySelector("ytd-playlist-add-to-option-renderer.style-scope.ytd-add-to-playlist-renderer:last-child").style.marginBottom = '16px';
	}

	function addSortAndFilterToPopup() {
		if(!document.querySelector("ytd-add-to-playlist-renderer")) return;

		// Check to see if the popup dialog is on the page and is visible
		const popupDialog = document.querySelector(selectorPopupDialog);
		if (!popupDialog || getComputedStyle(popupDialog).display == 'none') return;


		// Check to see if the Sort button and Filter textbox have already been added to the popup dialog
		if (popupDialog.querySelector('#sort_save_to') && popupDialog.querySelector('.filter_save_to')) {
			// Sort button and Filter textbox already exist on the page, so exit this function
			// run filter function to reset the visibility of the playlist items
			keyupSearch();
			return;
		}

		var isPlaylistSorted = false;

		function alterPopupTitleBar() {
			const selectorPlayListMenuHeaderTitle = `ytd-add-to-playlist-renderer > #header > ytd-menu-title-renderer`;
			const playListMenuHeaderTitle = document.querySelector(selectorPlayListMenuHeaderTitle);
			const selectorPlayListFilterInput = selectorPlayListMenuHeaderTitle + ' input.filter_save_to';
			const selectorPlayListSortButton = selectorPlayListMenuHeaderTitle + ' button#sort_save_to';

			// Check to see if the popup dialog is on the page
			if (!playListMenuHeaderTitle) return;

			// We found the popup dialog, now add the sort button and filter textbox
			playListMenuHeaderTitle.style.width = '300px';

			// holds both the Sort button and Filter textbox
			const containerDiv = document.createElement('div');
			containerDiv.style.margin = '10px 0 0 10px';

			// Sort Button
			const sortButton = document.createElement('button');
			sortButton.id = 'sort_save_to';
			sortButton.style.cssText = 'background: #f8f8f8; border: 1px solid rgb(211,211,211); border-radius: 2px; color: #000; padding: 8px 16px; margin-right: 16px;';
			sortButton.textContent = 'A-Z ↓';

			// Filter Textbox
			const filterInput = document.createElement('input');
			filterInput.type = 'text';
			filterInput.className = 'filter_save_to';
			filterInput.style.cssText = 'background: #ffffff; color: #111111; padding: 8px 16px; border: 1px solid rgb(211,211,211); border-radius: 2px; width: 30%;';

			containerDiv.appendChild(sortButton);
			containerDiv.appendChild(filterInput);
			playListMenuHeaderTitle.appendChild(containerDiv);

			// Filter Textbox Input event handler
			if (document.querySelector(selectorPlayListFilterInput)) {
				document.querySelector(selectorPlayListFilterInput).addEventListener('keyup', function (e) {
					keyupSearch(e);
				})
			}

			// Sort Button event handler
			if (document.querySelector(selectorPlayListSortButton)) {
				document.querySelector(selectorPlayListSortButton).addEventListener('click', function (e) {
					var elPlayList = document.querySelector("#playlists.ytd-add-to-playlist-renderer");
					elPlayList.style.display = "flex";
					elPlayList.style.flexDirection = "column";

					if (!isPlaylistSorted) {
						// sort the list
						var arr = [];
						document.querySelectorAll("#playlists yt-formatted-string").forEach(function (item) {
							arr.push(item.innerHTML); // arr.push(item.textContent);
						})
						let filtered = arr.filter(function (item) {
							return item != '<!--css-build:shady-->'
						})
						filtered.sort().forEach(function (sort1, sortedIndex) {
							document.querySelectorAll("#playlists yt-formatted-string").forEach(function (elPlaylistName, index) {
								if (sort1 == elPlaylistName.innerHTML) { //if (sort1 === elPlaylistName.textContent) {
									var elPlaylistItem = elPlaylistName.closest("ytd-playlist-add-to-option-renderer");
									var originalSortOrder = index;
									elPlaylistItem.setAttribute('data-origOrder', originalSortOrder); // store the original sort order so we can undo the sort operation
									elPlaylistItem.style.order = sortedIndex + 1; // new sort order
								}
							})
						})
						document.querySelector("ytd-playlist-add-to-option-renderer.style-scope.ytd-add-to-playlist-renderer:last-child").style.marginBottom = '16px';

						isPlaylistSorted = true;
						document.querySelector(selectorPlayListSortButton).style.background = '#88d988'; //bdbdbd

					} else {
						// unsort the list
						document.querySelectorAll("#playlists yt-formatted-string").forEach(function (elPlaylistName) {
							var elPlaylistItem = elPlaylistName.closest("ytd-playlist-add-to-option-renderer");
							var originalSortOrder = elPlaylistItem.getAttribute('data-origOrder');
							elPlaylistItem.style.order = originalSortOrder; // new sort order
						})
						document.querySelector("ytd-playlist-add-to-option-renderer.style-scope.ytd-add-to-playlist-renderer:last-child").style.marginBottom = '16px';

						isPlaylistSorted = false;
						document.querySelector(selectorPlayListSortButton).style.background = '#f8f8f8';
					}
				})
			}
		}

		// We found the popup dialog, now add the sort button and filter textbox
		alterPopupTitleBar();
	}

	/* This area for Observers that will trigger addSortAndFilterToPopup() when popup appears on page
		Scenario 1: when <tp-yt-paper-dialog> is appended to <ytd-popup-container>
		Scenario 2: when "display: none" style is removed from the element <tp-yt-paper-dialog>

		We may need to use different observers to handle all scenarios.
		Observer (observePopupDisplayed): when "display: none" style is removed from the element <tp-yt-paper-dialog>
		Observer (observePopupCreated): when <tp-yt-paper-dialog> is appended to <ytd-popup-container>
	*/

	// Use IntersectionObserver to observe when popup dialogue is displayed
	const observePopupDisplayed = new IntersectionObserver((entries, observePopupDisplayed) => {
		entries.forEach(entry => {
			if (entry.intersectionRatio > 0) {
				// Visible
				addSortAndFilterToPopup();
				keyupSearch();
			}
		});
	});
	function watchPopupDialogueVisibility() {
		// Begin observing playlist dialogue visibility changes so that we can re-add the Sort button and Filter textbox to the popup dialog if they are removed
		//observePopupDisplayed.observe(document.querySelector(selectorPopupDialog), { attributes: true, attributeFilter: ['style', 'class'], });
		observePopupDisplayed.observe(document.querySelector(selectorPopupDialog), { root: document.documentElement, threshold: 0.1 });
	}

	// Observer (observePopupCreated): when <tp-yt-paper-dialog> is appended to <ytd-popup-container> ; This only happens once (after initial page load of site).
	const observePopupCreated = new MutationObserver(function (mutations) {
		mutations.forEach(function (mutation) {
			if (mutation.addedNodes.length) {
				mutation.addedNodes.forEach(function (addedNode) {

					// String comparison is case-sensitive, so we need to use localeCompare() to do case-insensitive string comparison
					if (addedNode.nodeName.localeCompare(selectorPopupDialog, undefined, { sensitivity: 'base' }) === 0) {
						// Popup dialogue has been added to the page, so now we can start observing when popup dialogue is displayed

						// We no longer need this observer, so disconnect it
						observePopupCreated.disconnect();

						// Run the function to add the Sort button and Filter textbox to the popup dialog
						addSortAndFilterToPopup();

						// Start observing popup dialogue visibility changes so that we can re-add the Sort button and Filter textbox to the popup dialog if they are removed
						watchPopupDialogueVisibility();
					}
				})
			}
		});
	});
	function waitForPopupDialogueCreation() {
		// Check to see if the popup container is on the page
		if (document.querySelector(selectorPopupDialog)) {
			// Popup container already exists, move onto next step
			watchPopupDialogueVisibility();
		} else {
			// Begin observing when popup dialogue is added to popup container
			observePopupCreated.observe(document.querySelector(selectorPopupContainer), { childList: true, subtree: false });
		}
	}

	function waitForPopupContainerLoad() {
		if (document.querySelector(selectorPopupContainer)) {
			// Popup container already exists, move onto next step
			waitForPopupDialogueCreation();
		} else {
			// Begin observing when popup container is added to page

			// Create interval to check for popup container
			var intervalCheckForPopupContainer = setInterval(() => {
				if (document.querySelector(selectorPopupContainer)) {
					// We no longer need this interval, so clear it
					clearInterval(intervalCheckForPopupContainer);

					// Popup container exists on page, so now we can start observing when popup dialogue is added to popup container
					waitForPopupDialogueCreation();
				}
			}, 250);
		}
	}

	function waitForAppLoad() {
		if (document.querySelector(selectorApp)) {
			// App already exists, move onto next step
			waitForPopupContainerLoad();
		} else {
			// Begin observing when app is added to page

			// Create interval to check for app
			var intervalCheckForApp = setInterval(() => {
				if (document.querySelector(selectorApp)) {
					// We no longer need this interval, so clear it
					clearInterval(intervalCheckForApp);

					waitForPopupContainerLoad();
				}
			}, 250);
		}
	}

	waitForAppLoad();

})();