YouTube Quick Actions

Adds quick-action buttons like Hide, Save to Playlist, Not Interested, and Don’t Recommend

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

您可能也喜欢Weverse Extra

安装此脚本
  1. // ==UserScript==
  2. // @name YouTube Quick Actions
  3. // @description Adds quick-action buttons like Hide, Save to Playlist, Not Interested, and Don’t Recommend
  4. // @version 1.6.1
  5. // @match https://www.youtube.com/*
  6. // @license Unlicense
  7. // @icon https://www.youtube.com/s/desktop/c722ba88/img/logos/favicon_144x144.png
  8. // @grant GM_addStyle
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_unregisterMenuCommand
  13. // @compatible firefox
  14. // @namespace https://greasyfork.org/users/1223791
  15. // ==/UserScript==
  16.  
  17. "use strict";
  18.  
  19. console.log("🫡 [Youtube Quick Actions] Script initialized");
  20.  
  21. const css = String.raw;
  22. const style = css`
  23. #quick-actions {
  24. position: absolute;
  25. display: none;
  26. flex-direction: column;
  27. gap: 0.2em;
  28. align-items: flex-start;
  29. }
  30.  
  31. .location-01 {
  32. top: 0.8em;
  33. left: 0.8em;
  34. }
  35.  
  36. .location-02 {
  37. top: 0.4em;
  38. left: 0.4em;
  39. }
  40.  
  41. .qa-button {
  42. background-color: rgba(0, 0, 0, 0.9);
  43. /* box-shadow: inset 2px 3px 5px #000, 0px 0px 8px #d0d0d02e; */
  44. z-index: 1000;
  45. border: 1px solid #f0f0f05c;
  46. width: 26px;
  47. height: 26px;
  48. display: flex;
  49. justify-content: center;
  50. align-items: center;
  51. color: white;
  52. font-size: 16px;
  53. font-weight: bold;
  54. border-radius: 4px;
  55. cursor: pointer;
  56. flex-shrink: unset;
  57. }
  58.  
  59. .qa-button:hover {
  60. border: 1px solid rgba(255, 255, 255, 0.2);
  61. opacity: 0.9;
  62. background-color: rgba(55, 55, 55, 0.9);
  63. }
  64.  
  65. .qa-icon {
  66. width: 1em;
  67. height: 1em;
  68. vertical-align: -0.125em;
  69. }
  70.  
  71. YTD-RICH-ITEM-RENDERER:hover:not(:has(ytd-rich-grid-media[is-dismissed])):not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
  72. YTD-COMPACT-VIDEO-RENDERER:hover:not([is-dismissed]):not(:has(#dismissed-content)) #quick-actions,
  73. YTM-SHORTS-LOCKUP-VIEW-MODEL-V2:hover:not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
  74. YT-LOCKUP-VIEW-MODEL:hover:not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
  75. YTD-PLAYLIST-VIDEO-RENDERER:hover #quick-actions,
  76. YTD-VIDEO-RENDERER:hover #quick-actions,
  77. YTD-GRID-VIDEO-RENDERER:hover #quick-actions {
  78. display: flex;
  79. }
  80.  
  81. /*
  82. #dismissible:hover:not(:has(ytm-shorts-lockup-view-model-v2)) > #quick-actions {
  83. display: flex;
  84. }
  85. */
  86.  
  87. YT-LOCKUP-VIEW-MODEL:hover:has(#quick-actions),
  88. YTD-PLAYLIST-VIDEO-RENDERER:hover:has(#quick-actions) {
  89. position: relative;
  90. }
  91. `;
  92.  
  93. GM_addStyle(style);
  94.  
  95. /* -------------------------------------------------------------------------- */
  96. /* Variables */
  97. /* -------------------------------------------------------------------------- */
  98.  
  99. // Elem to search for
  100. const normalVideoTagName = "YTD-RICH-ITEM-RENDERER";
  101. const searchVideoTagName = "YTD-VIDEO-RENDERER";
  102. const gridVideoTagName = "YTD-GRID-VIDEO-RENDERER";
  103. const compactVideoTagName = "YTD-COMPACT-VIDEO-RENDERER";
  104. const shortsV2VideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL-V2";
  105. const shortsVideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL";
  106. const compactPlaylistContainer = "YTD-ITEM-SECTION-RENDERER";
  107. const compactPlaylistSelector = ".yt-lockup-view-model-wiz";
  108. const playlistVideoTagName = "YT-LOCKUP-VIEW-MODEL";
  109. const playlistVideoTagName2 = "YTD-PLAYLIST-VIDEO-RENDERER";
  110. const memberVideoTagName = "YTD-MEMBERSHIP-BADGE-RENDERER";
  111. const memberVideoSelector = ".badge-style-type-members-only";
  112. const thumbnailElementSelector = "img.yt-core-image";
  113. const normalHamburgerMenuSelector = "button#button.style-scope.yt-icon-button";
  114. const shortsAndPlaylistHamburgerMenuSelector = "button.yt-spec-button-shape-next";
  115. const dropdownMenuTagName = "TP-YT-IRON-DROPDOWN";
  116. const popupMenuItemsSelector = "yt-formatted-string.style-scope.ytd-menu-service-item-renderer, yt-list-item-view-model[role='menuitem']";
  117. //Menu Extractions / Properties Path
  118. const searchMenuPropertyPath = "menu.menuRenderer.items";
  119. const gridMenuPropertyPath = "menu.menuRenderer.items";
  120. const shortsMenuPropertyPath = "content.shortsLockupViewModel.menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  121. const shortsV2MenuPropertyPath = "menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  122. const normalMenuPropertyPath = "content.videoRenderer.menu.menuRenderer.items";
  123. const playlistMenuPropertyPath = "content.lockupViewModel.metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  124. const playlistMenuPropertyPath2 = "menu.menuRenderer.items";
  125. const compactPlaylistMenuPropertyPath = "metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  126. const compactMenuPropertyPath = "menu.menuRenderer.items";
  127. const membersOnlyMenuPropertyPath = "content.feedEntryRenderer.item.videoRenderer.menu.menuRenderer.items";
  128. const membersOnlyMenuPropertyPath2 = "content.videoRenderer.menu.menuRenderer.items";
  129. const availableMenuItemsList1 = "listItemViewModel?.title?.content";
  130. const availableMenuItemsList2 = "menuServiceItemRenderer?.text?.runs?.[0]?.text";
  131. const normalVideoRichThumbnailPath = "content?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails?.[0]?.url";
  132. const normalVideoThumbnailPath = "content?.videoRenderer?.thumbnail?.thumbnails";
  133. const compactVideoRichThumbnailPath = "richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails?.[0]?.url";
  134. const compactVideoThumbnailPath = "thumbnail?.thumbnails";
  135.  
  136. // <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
  137. const notInterestedIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7c-2.6 8.4-11.6 13.2-20 10.5s-13.2-11.6-10.5-20C145.2 326.1 196.3 288 256 288s110.8 38.1 127.3 91.3c2.6 8.4-2.1 17.4-10.5 20s-17.4-2.1-20-10.5C340.5 349.4 302.1 320 256 320s-84.5 29.4-96.7 68.7zM144.4 208a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm192-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`;
  138. const frownIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM176.4 176a32 32 0 1 1 0 64 32 32 0 1 1 0-64zm128 32a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm-122 174.5c-12.4 5.2-26.5-4.1-21.1-16.4c16-36.6 52.4-62.1 94.8-62.1s78.8 25.6 94.8 62.1c5.4 12.3-8.7 21.6-21.1 16.4c-22.4-9.5-47.4-14.8-73.7-14.8s-51.3 5.3-73.7 14.8z"/></svg>`;
  139. const saveIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M512 416c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96C0 60.7 28.7 32 64 32l128 0c20.1 0 39.1 9.5 51.2 25.6l19.2 25.6c6 8.1 15.5 12.8 25.6 12.8l160 0c35.3 0 64 28.7 64 64l0 256zM232 376c0 13.3 10.7 24 24 24s24-10.7 24-24l0-64 64 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-64 0 0-64c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 64-64 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l64 0 0 64z"/></svg>`;
  140. const dontRecommendChannelIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M367.2 412.5L99.5 144.8C77.1 176.1 64 214.5 64 256c0 106 86 192 192 192c41.5 0 79.9-13.1 111.2-35.5zm45.3-45.3C434.9 335.9 448 297.5 448 256c0-106-86-192-192-192c-41.5 0-79.9 13.1-111.2 35.5L412.5 367.2zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"/></svg>`;
  141. const hideIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"/></svg>`;
  142. const pooIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M268.9 .9c-5.5-.7-11 1.4-14.5 5.7s-4.6 10.1-2.8 15.4c2.8 8.2 4.3 16.9 4.3 26.1c0 44.1-35.7 79.9-79.8 80L160 128c-35.3 0-64 28.7-64 64c0 19.1 8.4 36.3 21.7 48L104 240c-39.8 0-72 32.2-72 72c0 23.2 11 43.8 28 57c-34.1 5.7-60 35.3-60 71c0 39.8 32.2 72 72 72l368 0c39.8 0 72-32.2 72-72c0-35.7-25.9-65.3-60-71c17-13.2 28-33.8 28-57c0-39.8-32.2-72-72-72l-13.7 0c13.3-11.7 21.7-28.9 21.7-48c0-35.3-28.7-64-64-64l-5.5 0c3.5-10 5.5-20.8 5.5-32c0-48.6-36.2-88.8-83.1-95.1zM192 256a32 32 0 1 1 0 64 32 32 0 1 1 0-64zm96 32a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm64 108.3c0 2.4-.7 4.8-2.2 6.7c-8.2 10.5-39.5 45-93.8 45s-85.6-34.6-93.8-45c-1.5-1.9-2.2-4.3-2.2-6.7c0-6.8 5.5-12.3 12.3-12.3l167.4 0c6.8 0 12.3 5.5 12.3 12.3z"/></svg>`;
  143. const mehIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM176.4 176a32 32 0 1 1 0 64 32 32 0 1 1 0-64zm128 32a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM160 336l192 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-192 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z"/></svg>`;
  144. const trashIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M135.2 17.7C140.6 6.8 151.7 0 163.8 0L284.2 0c12.1 0 23.2 6.8 28.6 17.7L320 32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 7.2-14.3zM32 128l384 0 0 320c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-320zm96 64c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16z"/></svg>`;
  145.  
  146.  
  147. /* -------------------------------------------------------------------------- */
  148. /* Functions */
  149. /* -------------------------------------------------------------------------- */
  150.  
  151. /* ----------------------------- Menu Commmands ----------------------------- */
  152.  
  153. let isLoggingEnabled = GM_getValue("isLoggingEnabled", false);
  154. let optRichThumbnail = GM_getValue("optRichThumbnail", true);
  155. const menuCommands = [
  156. {
  157. label: () => `Rich Thumbnail: ${optRichThumbnail ? "ON" : "OFF"}`,
  158. toggle: function toggleRichThumbnail()
  159. {
  160. optRichThumbnail = !optRichThumbnail;
  161. GM_setValue("optRichThumbnail", optRichThumbnail);
  162. updateMenuCommands();
  163. window.location.reload(true);
  164. },
  165. id: undefined,
  166. },
  167. {
  168. label: () => `Logging: ${isLoggingEnabled ? "ON" : "OFF"}`,
  169. toggle: function toggleLogging()
  170. {
  171. isLoggingEnabled = !isLoggingEnabled;
  172. GM_setValue("isLoggingEnabled", isLoggingEnabled);
  173. updateMenuCommands();
  174. window.location.reload(true);
  175. },
  176. id: undefined,
  177. }
  178. ];
  179.  
  180. function registerMenuCommands()
  181. {
  182. for (const command of menuCommands)
  183. {
  184. command.id = GM_registerMenuCommand(command.label(), command.toggle);
  185. }
  186. }
  187.  
  188. function updateMenuCommands()
  189. {
  190. for (const command of menuCommands)
  191. {
  192. if (command.id)
  193. {
  194. GM_unregisterMenuCommand(command.id);
  195. }
  196. command.id = GM_registerMenuCommand(command.label(), command.toggle);
  197. }
  198. }
  199.  
  200. function toggleRichThumbnail()
  201. {
  202. optRichThumbnail = !optRichThumbnail;
  203. GM_setValue("toggle5050Endorsement", optRichThumbnail);
  204. updateMenuCommands();
  205. window.location.reload(true);
  206. }
  207.  
  208. function toggleLogging()
  209. {
  210. isLoggingEnabled = !isLoggingEnabled;
  211. GM_setValue("isLoggingEnabled", isLoggingEnabled);
  212. updateMenuCommands();
  213. window.location.reload(true);
  214. }
  215.  
  216. registerMenuCommands();
  217.  
  218. /* ---------------------------- Menu Commands End --------------------------- */
  219.  
  220. function log(...args)
  221. {
  222. if (isLoggingEnabled)
  223. {
  224. console.log(...args);
  225. }
  226. }
  227.  
  228.  
  229. function getByPathReduce(target, path)
  230. {
  231. return path.split('.').reduce((result, key) => result?.[key], target) ?? [];
  232. }
  233.  
  234. //Same result as getByPathReduce()
  235. function getByPathFunction(object, path)
  236. {
  237. try
  238. {
  239. return new Function('object', `return object.${path}`)(object) ?? [];
  240. } catch
  241. {
  242. return [];
  243. }
  244. }
  245.  
  246. function getDataProperty(origin, videoType)
  247. {
  248. const childQuerySelectors = {
  249. "shorts-v2": shortsVideoTagName,
  250. "compact-playlist": compactPlaylistSelector,
  251. };
  252. const selector = childQuerySelectors[videoType];
  253. const target = selector ? origin.querySelector(selector) : origin;
  254. return target?.data;
  255. }
  256.  
  257. function getMenuList(target)
  258. {
  259. return target.map(item =>
  260. {
  261. const first = getByPathFunction(item, availableMenuItemsList1);
  262. if (first.length) return first;
  263.  
  264. const second = getByPathFunction(item, availableMenuItemsList2);
  265. if (second.length) return second;
  266.  
  267. return null;
  268. }).filter(Boolean);
  269. }
  270.  
  271. function findElemInParentDomTree(originElem, targetSelector)
  272. {
  273. log(`🔍 Starting search from:`, originElem);
  274.  
  275. let node = originElem;
  276. while (node)
  277. {
  278. log(`👆 Checking ancestor:`, node);
  279. const found = Array.from(node.children).find(
  280. (child) => child.matches(targetSelector) || child.querySelector(targetSelector)
  281. );
  282.  
  283. if (found)
  284. {
  285. const result = found.matches(targetSelector) ? found : found.querySelector(targetSelector);
  286. log(`✅ Found target:`, result);
  287. return result;
  288. }
  289.  
  290. node = node.parentElement;
  291. }
  292.  
  293. log("⚠️ No matching element found.");
  294. return null;
  295. }
  296.  
  297. function getVisibleElem(targetSelector)
  298. {
  299. const elements = document.querySelectorAll(targetSelector);
  300. for (const element of elements)
  301. {
  302. const rect = element.getBoundingClientRect();
  303. if (element.offsetParent !== null && rect.width > 0 && rect.height > 0)
  304. {
  305. log("👀 Menu is visible and ready:", element);
  306. return element;
  307. }
  308. }
  309. log("⚠️ No visible menu found.");
  310. return null;
  311. }
  312.  
  313. async function waitUntil(conditionFunction, { interval = 100, timeout = 3000 } = {})
  314. {
  315. const startTime = Date.now();
  316. while (Date.now() - startTime < timeout)
  317. {
  318. const result = conditionFunction();
  319. if (result) return result;
  320. await new Promise((resolve) => setTimeout(resolve, interval));
  321. }
  322. throw new Error("⏰ Timeout: Target element is not visible in time");
  323. }
  324.  
  325. function retryClick(element, { maxAttempts = 5, interval = 300 } = {})
  326. {
  327. return new Promise((resolve) =>
  328. {
  329. let attempts = 0;
  330.  
  331. function tryClick()
  332. {
  333. if (!element || attempts >= maxAttempts)
  334. {
  335. log("⚠️ Retry failed or element missing.");
  336. return resolve();
  337. }
  338.  
  339. const rect = element.getBoundingClientRect();
  340. const isVisible = rect.width > 0 && rect.height > 0;
  341.  
  342. if (isVisible)
  343. {
  344. element.dispatchEvent(
  345. new MouseEvent("click", {
  346. view: document.defaultView,
  347. bubbles: true,
  348. cancelable: true,
  349. }),
  350. );
  351. log("👇 Clicked matching menu item");
  352. return resolve();
  353. } else
  354. {
  355. attempts++;
  356. setTimeout(tryClick, interval);
  357. }
  358. }
  359.  
  360. tryClick();
  361. });
  362. }
  363.  
  364.  
  365. function appendButtons(element, menuItems, type, position)
  366. {
  367. let className, titleText, icon;
  368. let buttonsToAppend = [];
  369.  
  370. const finalMenuItems = [...new Set(menuItems)];
  371.  
  372. //If menu is empty, proceed and still append the container to prevent looping of menu data probe.
  373. //Probe will only skip if #quick-action exist.
  374.  
  375. for (const item of finalMenuItems)
  376. {
  377. if (!item) continue;
  378.  
  379. let className;
  380. let titleText;
  381. let icon;
  382.  
  383. if (item.startsWith("Remove from "))
  384. {
  385. className = "remove";
  386. titleText = "Remove from playlist";
  387. icon = trashIcon;
  388. } else
  389. {
  390. switch (item)
  391. {
  392. case "Not interested":
  393. className = "not_interested";
  394. titleText = "Not interested";
  395. icon = notInterestedIcon;
  396. break;
  397. case "Don't recommend channel":
  398. className = "dont_recommend_channel";
  399. titleText = "Don't recommend channel";
  400. icon = dontRecommendChannelIcon;
  401. break;
  402. case "Hide":
  403. className = "hide";
  404. titleText = "Hide video";
  405. icon = hideIcon;
  406. break;
  407. case "Save to playlist":
  408. className = "save";
  409. titleText = "Save to playlist";
  410. icon = saveIcon;
  411. break;
  412. default:
  413. continue;
  414. }
  415. }
  416.  
  417. buttonsToAppend.push(
  418. `<button class="qa-button ${className}" data-icon="${className}" title="${titleText}" data-text="${titleText}">${icon}</button>`,
  419. );
  420. }
  421.  
  422. const buttonsContainer = document.createElement("div");
  423. buttonsContainer.id = "quick-actions";
  424. buttonsContainer.classList.add(position, type);
  425. buttonsContainer.innerHTML = buttonsToAppend.join("");
  426.  
  427. //element.insertAdjacentElement("afterend", buttonsContainer);
  428. const exist = element.querySelector("#quick-actions");
  429. if (exist) return;
  430. element.insertAdjacentElement("beforeend", buttonsContainer);
  431. }
  432.  
  433. /* -------------------------------------------------------------------------- */
  434. /* Listeners */
  435. /* -------------------------------------------------------------------------- */
  436.  
  437. // Remove all existing quick-action elements. On certain pages, like channel tabs, content is updated in-place
  438. // without removing the grid/container. If not cleared, old quick-action buttons will remain attached to unrelated items.
  439. // This ensures that if the content is updated, new hover actions will fetch fresh, relevant data.
  440. // I have not take a closer look at yt-made events. propably have some things we can customized and fire to speed things up
  441. // skip querying and fired the action straight up via their internal events
  442.  
  443. document.addEventListener("yt-action", (event) =>
  444. {
  445. if (event.detail.actionName === "yt-history-pop")
  446. {
  447. log("🐛 Page updated.");
  448. }
  449.  
  450. if (event.detail.actionName === "ytd-update-grid-state-action")
  451. {
  452. log("🐛 Page updated.");
  453. document.querySelectorAll("#quick-actions").forEach((element) => element.remove());
  454. }
  455.  
  456. });
  457.  
  458. let opThumbnail, riThumbnail;
  459. document.addEventListener("mouseover", (event) =>
  460. {
  461. const path = event.composedPath();
  462. for (let element of path)
  463. {
  464. if (
  465. (element.tagName === normalVideoTagName ||
  466. element.tagName === compactVideoTagName ||
  467. element.tagName === shortsV2VideoTagName ||
  468. element.tagName === searchVideoTagName ||
  469. element.tagName === gridVideoTagName ||
  470. element.tagName === playlistVideoTagName ||
  471. element.tagName === playlistVideoTagName2) &&
  472. !element.querySelector("#quick-actions")
  473. )
  474. {
  475. let type, data;
  476.  
  477. // Determine element type
  478. // Hierarchy might need tweaking to simplify detection. nah this whole listener block,
  479. // cause i'm already confused which tag is needed for which video, what need extra query, then which path
  480. // and specific video type wont get shown unless specific step is done, even then rarely replicable to debug
  481. // some of this type no longer valid as i go, cause i can't keep track no more
  482. if (element.tagName === shortsV2VideoTagName)
  483. {
  484. type = "shorts-v2";
  485. }
  486. else if (element.tagName === playlistVideoTagName && element.parentElement.parentElement.tagName === compactPlaylistContainer)
  487. {
  488. type = "compact-playlist";
  489. }
  490. else if (element.tagName === gridVideoTagName)
  491. {
  492. type = "grid-video";
  493. }
  494. else if (element.tagName === searchVideoTagName)
  495. {
  496. type = "search-video";
  497. }
  498. else if (element.tagName === playlistVideoTagName && element.parentElement.parentElement.tagName === normalVideoTagName)
  499. {
  500. //hover listener will land on playlistVideoTagName instead of normalVideoTagName for playlist/mixes on homepage
  501. //so manually change back to normalVideoTagName as data is there.
  502. element = element.parentElement.parentElement;
  503. if (element.querySelector("#quick-actions")) return;
  504. type = "playlist";
  505. }
  506. else if (element.tagName === playlistVideoTagName2)
  507. {
  508. type = "playlist2";
  509. }
  510. else
  511. {
  512. const isShort = element.querySelector(shortsVideoTagName) !== null;
  513. const isPlaylist = element.querySelector(playlistVideoTagName) !== null;
  514. const isMemberOnly =
  515. element.querySelector(memberVideoTagName) !== null ||
  516. element.querySelector(memberVideoSelector) !== null;
  517.  
  518. type = isShort ? "shorts" :
  519. element.tagName === compactVideoTagName ? "compact" :
  520. isPlaylist ? "collection" :
  521. isMemberOnly ? "members_only" :
  522. "normal";
  523. }
  524.  
  525. log("⭐ Video Elem: ", element.tagName, element);
  526. log("ℹ️ Video Type: ", type);
  527.  
  528. data = getDataProperty(element, type);
  529. const thumbnailElement = element.querySelector(thumbnailElementSelector);
  530. const thumbnailSize =
  531. thumbnailElement?.getClientRects?.().length > 0
  532. ? parseInt(thumbnailElement.getClientRects()[0].width)
  533. : 100;
  534. log("🖼️ Thumbnail Size: ", thumbnailSize);
  535. const containerPosition = thumbnailSize < 211 ? "location-02" : "location-01";
  536.  
  537. if (!data)
  538. {
  539. log("⚠️ No props data found.");
  540. return;
  541. }
  542.  
  543. log("🎥 Video Props: ", data);
  544.  
  545. let menulist;
  546. switch (type)
  547. {
  548. case "normal":
  549. menulist = getByPathFunction(data, normalMenuPropertyPath);
  550. break;
  551. case "search-video":
  552. menulist = getByPathFunction(data, searchMenuPropertyPath);
  553. break;
  554. case "grid-video":
  555. menulist = getByPathFunction(data, gridMenuPropertyPath);
  556. break;
  557. case "shorts":
  558. menulist = getByPathFunction(data, shortsMenuPropertyPath);
  559. break;
  560. case "shorts-v2":
  561. menulist = getByPathFunction(data, shortsV2MenuPropertyPath);
  562. break;
  563. case "compact":
  564. menulist = getByPathFunction(data, compactMenuPropertyPath);
  565. break;
  566. case "collection":
  567. menulist = getByPathFunction(data, playlistMenuPropertyPath);
  568. break;
  569. case "playlist":
  570. menulist = getByPathFunction(data, playlistMenuPropertyPath);
  571. break;
  572. case "playlist2":
  573. menulist = getByPathFunction(data, playlistMenuPropertyPath2);
  574. break;
  575. case "compact-playlist":
  576. menulist = getByPathFunction(data, compactPlaylistMenuPropertyPath);
  577. break;
  578. case "members_only":
  579. menulist = getByPathFunction(data, membersOnlyMenuPropertyPath);
  580. if (!menulist.length)
  581. {
  582. menulist = getByPathFunction(data, membersOnlyMenuPropertyPath2);
  583. }
  584. break;
  585. default:
  586. menulist = getByPathFunction(data, normalMenuPropertyPath);
  587. break;
  588. }
  589.  
  590.  
  591. const menulistItems = getMenuList(menulist);
  592. log("📃 Menu items: ", menulistItems);
  593. appendButtons(element, menulistItems, type, containerPosition);
  594.  
  595. //Rich Thumbnails
  596. if (optRichThumbnail)
  597. {
  598. element.addEventListener("mouseover", (event) =>
  599. {
  600. const currentThumbnail = element.querySelector("img.yt-core-image");
  601. const thumbailData = getDataProperty(element, type);
  602. const normalRichThumbnail = getByPathFunction(thumbailData, normalVideoRichThumbnailPath);
  603. const compactRichThumbnail = getByPathFunction(thumbailData, compactVideoRichThumbnailPath);
  604. const richThumbnail =
  605. (typeof normalRichThumbnail === 'string' && normalRichThumbnail) ||
  606. (typeof compactRichThumbnail === 'string' && compactRichThumbnail) ||
  607. undefined;
  608. if (richThumbnail)
  609. {
  610. currentThumbnail.src = richThumbnail;
  611. }
  612.  
  613. }, true);
  614. element.addEventListener("mouseout", (event) =>
  615. {
  616. const currentThumbnail = element.querySelector("img.yt-core-image");
  617. const thumbnailData = getDataProperty(element, type);
  618. const normalThumbnails = getByPathFunction(thumbnailData, normalVideoThumbnailPath);
  619. const compactThumbnails = getByPathFunction(thumbnailData, compactVideoThumbnailPath);
  620.  
  621. const biggestNormalThumbnail = normalThumbnails.at(-1)?.url;
  622. const biggestCompactThumbnail = compactThumbnails.at(-1)?.url;
  623. const staticThumbnail = biggestNormalThumbnail || biggestCompactThumbnail;
  624.  
  625. if (staticThumbnail)
  626. {
  627. currentThumbnail.src = staticThumbnail;
  628. }
  629.  
  630. }, true);
  631. }
  632.  
  633. }
  634. }
  635. }, true);
  636.  
  637. document.addEventListener("click", async function (event)
  638. {
  639. const button = event.target.closest(".qa-button");
  640. if (!button) return;
  641.  
  642. event.stopPropagation();
  643. event.stopImmediatePropagation();
  644. event.preventDefault();
  645.  
  646. const actionType = button.dataset.icon;
  647. let response;
  648.  
  649. switch (actionType)
  650. {
  651. case "not_interested":
  652. response = "Not interested";
  653. log("😴 Marking as not interested");
  654. break;
  655. case "dont_recommend_channel":
  656. response = "Don't recommend channel";
  657. log("🚫 Don't recommend channel");
  658. break;
  659. case "hide":
  660. response = "Hide";
  661. log("🗑️ Hiding video");
  662. break;
  663. case "remove":
  664. response = "Remove from";
  665. log("🗑️ Remove from playlist");
  666. break;
  667. case "save":
  668. response = "Save to playlist";
  669. log("📂 Saving to playlist");
  670. break;
  671. default:
  672. log("☠️ Unknown action");
  673. }
  674.  
  675. let menupath;
  676.  
  677. if (button.parentElement.parentElement.tagName === shortsV2VideoTagName || button.parentElement.parentElement.querySelector(playlistVideoTagName))
  678. {
  679. menupath = shortsAndPlaylistHamburgerMenuSelector;
  680. }
  681. else if (button.parentElement.classList.contains("shorts"))
  682. {
  683. //shorts but not inside shortsv2 container idk where i found this its gone now crazy i was crazy once
  684. alert("shorts!");
  685. menupath = shortsAndPlaylistHamburgerMenuSelector;
  686. }
  687. else if (button.parentElement.classList.contains("compact-playlist"))
  688. {
  689. menupath = shortsAndPlaylistHamburgerMenuSelector;
  690. }
  691. else
  692. {
  693. menupath = normalHamburgerMenuSelector;
  694. }
  695.  
  696. const menus = findElemInParentDomTree(button, menupath);
  697. if (!menus)
  698. {
  699. log("❌ Menu button not found.");
  700. return;
  701. }
  702.  
  703. menus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  704. log("👇 Button clicked, waiting for menu...");
  705.  
  706. try
  707. {
  708. const visibleMenu = await waitUntil(() => getVisibleElem(dropdownMenuTagName), {
  709. interval: 100,
  710. timeout: 3000,
  711. });
  712. if (visibleMenu)
  713. {
  714. try
  715. {
  716. const targetItem = await waitUntil(
  717. () =>
  718. {
  719. const items = visibleMenu.querySelectorAll(popupMenuItemsSelector);
  720. return items.length > 0 ? items : null;
  721. },
  722. {
  723. interval: 100,
  724. timeout: 5000,
  725. },
  726. );
  727.  
  728. if (targetItem)
  729. {
  730. log("🎉 Target items found:", targetItem);
  731.  
  732. for (const item of targetItem)
  733. {
  734. if (
  735. item.textContent === response ||
  736. (response === "Remove from" && item.textContent.startsWith("Remove from"))
  737. )
  738. {
  739. log(`✅ Matched: (${response} = ${item.textContent})`);
  740. log(`✅`, item);
  741.  
  742. const button = item;
  743. await retryClick(button, { maxAttempts: 5, interval: 300 }).finally(() =>
  744. {
  745. document.body.click();
  746. });
  747. break;
  748. } else
  749. {
  750. log(`❌ Not a match: (${response} = ${item.textContent})`);
  751. }
  752. }
  753. }
  754. } catch (error)
  755. {
  756. log("🛑 !", error.message);
  757. //document.body.click()
  758. }
  759. }
  760.  
  761. //setTimeout(() => document.body.click(), 200);
  762. } catch (error)
  763. {
  764. log("🛑 !!", error.message);
  765. //document.body.click()
  766. }
  767. });