Youtube Mobile-like Playlist Remove Video Button

Adds a button to remove videos from playlists just like on mobile

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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