Greasy Fork is available in English.

Youtube Mobile-like Playlist Remove Video Button

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

安装此脚本?
作者推荐脚本

您可能也喜欢Toggle Youtube Styles

安装此脚本
  1. // ==UserScript==
  2. // @name Youtube Mobile-like Playlist Remove Video Button
  3. // @license MIT
  4. // @namespace rtonne
  5. // @match https://www.youtube.com/*
  6. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  7. // @version 1.7
  8. // @author Rtonne
  9. // @description Adds a button to remove videos from playlists just like on mobile
  10. // @run-at document-end
  11. // @grant GM.addStyle
  12. // ==/UserScript==
  13.  
  14. GM.addStyle(`
  15. ytd-playlist-video-renderer:hover .rtonne-youtube-playlist-delete-button {
  16. width: var(--yt-icon-width);
  17. }
  18. .rtonne-youtube-playlist-delete-button {
  19. width: 0;
  20. background-color: var(--yt-spec-additive-background);
  21. fill: var(--yt-spec-text-primary);
  22. border-width: 0;
  23. padding: 0;
  24. overflow: hidden;
  25. cursor: pointer;
  26. }
  27. .rtonne-youtube-playlist-delete-button:hover {
  28. background-color: var(--yt-spec-static-brand-red);
  29. }
  30. body.rtonne-youtube-playlist-delete-button-in-progress .rtonne-youtube-playlist-delete-button {
  31. pointer-events: none;
  32. }
  33. body.rtonne-youtube-playlist-delete-button-in-progress .rtonne-youtube-playlist-delete-button > div > svg {
  34. display: none !important;
  35. }
  36. /* From https://cssloaders.github.io */
  37. body.rtonne-youtube-playlist-delete-button-in-progress .rtonne-youtube-playlist-delete-button > div {
  38. width: 24px;
  39. height: 24px;
  40. border: 3px solid var(--yt-spec-text-primary);
  41. border-bottom-color: transparent;
  42. border-radius: 50%;
  43. display: inline-block;
  44. box-sizing: border-box;
  45. animation: rotation 2s linear infinite;
  46. }
  47. @keyframes rotation {
  48. 0% {
  49. transform: rotate(0deg);
  50. }
  51. 100% {
  52. transform: rotate(360deg);
  53. }
  54. }
  55. `);
  56.  
  57. let currentUrl = null;
  58.  
  59. const urlRegex = /^https:\/\/www.youtube.com\/playlist\?list=.*$/;
  60.  
  61. // Using observer to run script whenever the body changes
  62. // because youtube doesn't reload when changing page
  63. const observer = new MutationObserver(async () => {
  64. try {
  65. let newUrl = window.location.href;
  66.  
  67. // Because youtube doesn't reload on changing url
  68. // we have to allow the whole website and check here if we are in a playlist
  69. if (!urlRegex.test(newUrl)) {
  70. return;
  71. }
  72. const elements = await waitForElements(
  73. document,
  74. "ytd-playlist-video-renderer",
  75. );
  76.  
  77. // If the url is different we are in a different playlist
  78. // Or if the playlist length is different, we loaded more of the same playlist
  79. if (
  80. currentUrl === newUrl &&
  81. elements.length ===
  82. document.querySelectorAll(".rtonne-youtube-playlist-delete-button")
  83. .length
  84. ) {
  85. return;
  86. }
  87.  
  88. currentUrl = newUrl;
  89.  
  90. // If the list cannot be sorted, we assume we can't remove from it either
  91. if (
  92. !document.querySelector(
  93. "#header-container > #filter-menu > yt-sort-filter-sub-menu-renderer",
  94. )
  95. ) {
  96. return;
  97. }
  98.  
  99. elements.forEach((element) => {
  100. // Youtube reuses elements, so we check if element already has a button
  101. if (element.querySelector(".rtonne-youtube-playlist-delete-button"))
  102. return;
  103.  
  104. // ===========
  105. // Now we create the button and add it to each video
  106. // ===========
  107.  
  108. const elementStyle = document.defaultView.getComputedStyle(element);
  109. const button = document.createElement("button");
  110. button.className = "rtonne-youtube-playlist-delete-button";
  111. button.style.height = elementStyle.height;
  112. button.style.borderRadius = `0 ${elementStyle.borderTopRightRadius} ${elementStyle.borderBottomRightRadius} 0`;
  113. button.append(getYoutubeTrashSvg());
  114.  
  115. element.appendChild(button);
  116.  
  117. button.onclick = async () => {
  118. document.body.classList.add(
  119. "rtonne-youtube-playlist-delete-button-in-progress",
  120. );
  121.  
  122. // Click the 3 dot menu button on the video
  123. element.querySelector("button.yt-icon-button").click();
  124.  
  125. const [popup] = await waitForElements(
  126. document,
  127. "tp-yt-iron-dropdown.ytd-popup-container:has(> div > ytd-menu-popup-renderer):not([style*='display: none;'])",
  128. );
  129.  
  130. // Set the popup left to -10000px to hide it
  131. popup.style.left = "-10000px";
  132.  
  133. const [popup_remove_button] = await waitForElements(
  134. popup,
  135. `ytd-menu-service-item-renderer:has(path[d="${getSvgPathD()}"])`,
  136. );
  137. await removeVideo(popup_remove_button, element);
  138.  
  139. // In case of error and the popup doesn't hide
  140. document.body.click();
  141. document.body.classList.remove(
  142. "rtonne-youtube-playlist-delete-button-in-progress",
  143. );
  144. };
  145. });
  146. } catch (err) {
  147. console.error(err);
  148. }
  149. });
  150. observer.observe(document.body, {
  151. childList: true,
  152. subtree: true,
  153. });
  154.  
  155. // I couldn't check if we changed from an editable list to a non-editable list
  156. // in the other observer, so I have this one to just do that and remove the buttons
  157. const sortObserver = new MutationObserver(() => {
  158. if (!urlRegex.test(window.location.href)) {
  159. return;
  160. }
  161. if (
  162. !document.querySelector(
  163. "#header-container > #filter-menu > yt-sort-filter-sub-menu-renderer",
  164. )
  165. ) {
  166. document
  167. .querySelectorAll(".rtonne-youtube-playlist-delete-button")
  168. .forEach((element) => element.remove());
  169. }
  170. });
  171. sortObserver.observe(document.body, {
  172. childList: true,
  173. subtree: true,
  174. });
  175.  
  176. function getYoutubeTrashSvg() {
  177. const xmlns = "http://www.w3.org/2000/svg";
  178. const container = document.createElement("div");
  179. container.setAttribute("style", "height: 24px;");
  180. const svg = document.createElementNS(xmlns, "svg");
  181. svg.setAttribute("enable-background", "new 0 0 24 24");
  182. svg.setAttribute("height", "24");
  183. svg.setAttribute("width", "24");
  184. svg.setAttribute("viewbox", "0 0 24 24");
  185. svg.setAttribute("focusable", "false");
  186. svg.setAttribute(
  187. "style",
  188. "pointer-events: none;display: block;margin: auto;",
  189. );
  190. container.append(svg);
  191. const path = document.createElementNS(xmlns, "path");
  192. path.setAttribute("d", getSvgPathD());
  193. svg.append(path);
  194. return container;
  195. }
  196.  
  197. // This function is separate to find the menu's remove button in the observer
  198. function getSvgPathD() {
  199. return "M11 17H9V8h2v9zm4-9h-2v9h2V8zm4-4v1h-1v16H6V5H5V4h4V3h6v1h4zm-2 1H7v15h10V5z";
  200. }
  201.  
  202. /**
  203. * Uses a MutationObserver to wait until the element we want exists.
  204. * This function is required because elements take a while to appear sometimes.
  205. * https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists
  206. * @param {HTMLElement} node The element being used for querySelector
  207. * @param {string} selector A string for node.querySelector describing the elements we want.
  208. * @returns {Promise<HTMLElement[]>} The list of elements found.
  209. */
  210. function waitForElements(node, selector) {
  211. return new Promise((resolve) => {
  212. if (node.querySelector(selector)) {
  213. return resolve(node.querySelectorAll(selector));
  214. }
  215.  
  216. const observer = new MutationObserver(() => {
  217. if (node.querySelector(selector)) {
  218. observer.disconnect();
  219. resolve(node.querySelectorAll(selector));
  220. }
  221. });
  222.  
  223. observer.observe(document.body, {
  224. childList: true,
  225. subtree: true,
  226. attributeFilter: ["style"], // This needs to be used because in this case the selector can depend on style
  227. });
  228. });
  229. }
  230. /**
  231. * Removes the video that the popup belongs to.
  232. * Will try multiple times because of errors like "Precondition check failed".
  233. * @param {HTMLElement} popup_remove_button The popup button that remove the video.
  234. * @param {HTMLElement} element The element that represents the video being removed.
  235. * @returns
  236. */
  237. function removeVideo(popup_remove_button, element) {
  238. return new Promise((resolve) => {
  239. // Observer should trigger either when the element is removed
  240. // or an error notification appears
  241. const observer = new MutationObserver(() => {
  242. if (!document.contains(element)) {
  243. observer.disconnect();
  244. // disconnect and resolve don't immediately stop execution so return is also required
  245. return resolve();
  246. }
  247. popup_remove_button.click();
  248. });
  249.  
  250. observer.observe(document.body, {
  251. childList: true,
  252. subtree: true,
  253. });
  254.  
  255. popup_remove_button.click();
  256. });
  257. }