// ==UserScript==
// @name Youtube Mobile-like Playlist Remove Video Button
// @license MIT
// @namespace rtonne
// @match https://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @version 1.7
// @author Rtonne
// @description Adds a button to remove videos from playlists just like on mobile
// @run-at document-end
// @grant GM.addStyle
// ==/UserScript==
GM.addStyle(`
ytd-playlist-video-renderer:hover .rtonne-youtube-playlist-delete-button {
width: var(--yt-icon-width);
}
.rtonne-youtube-playlist-delete-button {
width: 0;
background-color: var(--yt-spec-additive-background);
fill: var(--yt-spec-text-primary);
border-width: 0;
padding: 0;
overflow: hidden;
cursor: pointer;
}
.rtonne-youtube-playlist-delete-button:hover {
background-color: var(--yt-spec-static-brand-red);
}
body.rtonne-youtube-playlist-delete-button-in-progress .rtonne-youtube-playlist-delete-button {
pointer-events: none;
}
body.rtonne-youtube-playlist-delete-button-in-progress .rtonne-youtube-playlist-delete-button > div > svg {
display: none !important;
}
/* From https://cssloaders.github.io */
body.rtonne-youtube-playlist-delete-button-in-progress .rtonne-youtube-playlist-delete-button > div {
width: 24px;
height: 24px;
border: 3px solid var(--yt-spec-text-primary);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 2s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`);
let currentUrl = null;
const urlRegex = /^https:\/\/www.youtube.com\/playlist\?list=.*$/;
// Using observer to run script whenever the body changes
// because youtube doesn't reload when changing page
const observer = new MutationObserver(async () => {
try {
let newUrl = window.location.href;
// Because youtube doesn't reload on changing url
// we have to allow the whole website and check here if we are in a playlist
if (!urlRegex.test(newUrl)) {
return;
}
const elements = await waitForElements(
document,
"ytd-playlist-video-renderer",
);
// If the url is different we are in a different playlist
// Or if the playlist length is different, we loaded more of the same playlist
if (
currentUrl === newUrl &&
elements.length ===
document.querySelectorAll(".rtonne-youtube-playlist-delete-button")
.length
) {
return;
}
currentUrl = newUrl;
// If the list cannot be sorted, we assume we can't remove from it either
if (
!document.querySelector(
"#header-container > #filter-menu > yt-sort-filter-sub-menu-renderer",
)
) {
return;
}
elements.forEach((element) => {
// Youtube reuses elements, so we check if element already has a button
if (element.querySelector(".rtonne-youtube-playlist-delete-button"))
return;
// ===========
// Now we create the button and add it to each video
// ===========
const elementStyle = document.defaultView.getComputedStyle(element);
const button = document.createElement("button");
button.className = "rtonne-youtube-playlist-delete-button";
button.style.height = elementStyle.height;
button.style.borderRadius = `0 ${elementStyle.borderTopRightRadius} ${elementStyle.borderBottomRightRadius} 0`;
button.append(getYoutubeTrashSvg());
element.appendChild(button);
button.onclick = async () => {
document.body.classList.add(
"rtonne-youtube-playlist-delete-button-in-progress",
);
// Click the 3 dot menu button on the video
element.querySelector("button.yt-icon-button").click();
const [popup] = await waitForElements(
document,
"tp-yt-iron-dropdown.ytd-popup-container:has(> div > ytd-menu-popup-renderer):not([style*='display: none;'])",
);
// Set the popup left to -10000px to hide it
popup.style.left = "-10000px";
const [popup_remove_button] = await waitForElements(
popup,
`ytd-menu-service-item-renderer:has(path[d="${getSvgPathD()}"])`,
);
await removeVideo(popup_remove_button, element);
// In case of error and the popup doesn't hide
document.body.click();
document.body.classList.remove(
"rtonne-youtube-playlist-delete-button-in-progress",
);
};
});
} catch (err) {
console.error(err);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// I couldn't check if we changed from an editable list to a non-editable list
// in the other observer, so I have this one to just do that and remove the buttons
const sortObserver = new MutationObserver(() => {
if (!urlRegex.test(window.location.href)) {
return;
}
if (
!document.querySelector(
"#header-container > #filter-menu > yt-sort-filter-sub-menu-renderer",
)
) {
document
.querySelectorAll(".rtonne-youtube-playlist-delete-button")
.forEach((element) => element.remove());
}
});
sortObserver.observe(document.body, {
childList: true,
subtree: true,
});
function getYoutubeTrashSvg() {
const xmlns = "http://www.w3.org/2000/svg";
const container = document.createElement("div");
container.setAttribute("style", "height: 24px;");
const svg = document.createElementNS(xmlns, "svg");
svg.setAttribute("enable-background", "new 0 0 24 24");
svg.setAttribute("height", "24");
svg.setAttribute("width", "24");
svg.setAttribute("viewbox", "0 0 24 24");
svg.setAttribute("focusable", "false");
svg.setAttribute(
"style",
"pointer-events: none;display: block;margin: auto;",
);
container.append(svg);
const path = document.createElementNS(xmlns, "path");
path.setAttribute("d", getSvgPathD());
svg.append(path);
return container;
}
// This function is separate to find the menu's remove button in the observer
function getSvgPathD() {
return "M11 17H9V8h2v9zm4-9h-2v9h2V8zm4-4v1h-1v16H6V5H5V4h4V3h6v1h4zm-2 1H7v15h10V5z";
}
/**
* Uses a MutationObserver to wait until the element we want exists.
* This function is required because elements take a while to appear sometimes.
* https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists
* @param {HTMLElement} node The element being used for querySelector
* @param {string} selector A string for node.querySelector describing the elements we want.
* @returns {Promise<HTMLElement[]>} The list of elements found.
*/
function waitForElements(node, selector) {
return new Promise((resolve) => {
if (node.querySelector(selector)) {
return resolve(node.querySelectorAll(selector));
}
const observer = new MutationObserver(() => {
if (node.querySelector(selector)) {
observer.disconnect();
resolve(node.querySelectorAll(selector));
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributeFilter: ["style"], // This needs to be used because in this case the selector can depend on style
});
});
}
/**
* Removes the video that the popup belongs to.
* Will try multiple times because of errors like "Precondition check failed".
* @param {HTMLElement} popup_remove_button The popup button that remove the video.
* @param {HTMLElement} element The element that represents the video being removed.
* @returns
*/
function removeVideo(popup_remove_button, element) {
return new Promise((resolve) => {
// Observer should trigger either when the element is removed
// or an error notification appears
const observer = new MutationObserver(() => {
if (!document.contains(element)) {
observer.disconnect();
// disconnect and resolve don't immediately stop execution so return is also required
return resolve();
}
popup_remove_button.click();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
popup_remove_button.click();
});
}