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.3
  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. :root {
  24. --color-primary: rgba(252, 146, 205, 1);
  25. --color-secondary: rgba(33, 225, 255, 1) ;
  26. }
  27.  
  28. #quick-actions {
  29. position: absolute;
  30. display: none;
  31. flex-direction: column;
  32. gap: 0.2em;
  33. align-items: flex-start;
  34. }
  35.  
  36. .location-01 {
  37. top: 0.8em;
  38. left: 0.8em;
  39. }
  40.  
  41. .location-02 {
  42. top: 0.4em;
  43. left: 0.4em;
  44. }
  45.  
  46. .qa-button {
  47. background-color: rgba(0, 0, 0, 0.9);
  48. /* box-shadow: inset 2px 3px 5px #000, 0px 0px 8px #d0d0d02e; */
  49. z-index: 1000;
  50. border: 1px solid #f0f0f05c;
  51. width: 26px;
  52. height: 26px;
  53. display: flex;
  54. justify-content: center;
  55. align-items: center;
  56. color: white;
  57. font-size: 16px;
  58. font-weight: bold;
  59. border-radius: 4px;
  60. cursor: pointer;
  61. flex-shrink: unset;
  62. }
  63.  
  64. .qa-button:hover {
  65. border: 1px solid rgba(255, 255, 255, 0.2);
  66. opacity: 0.9;
  67. background-color: rgba(55, 55, 55, 0.9);
  68. }
  69.  
  70. .qa-icon {
  71. width: 1em;
  72. height: 1em;
  73. vertical-align: -0.125em;
  74. }
  75.  
  76. YTD-RICH-ITEM-RENDERER:hover:not(:has(ytd-rich-grid-media[is-dismissed])):not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
  77. YTD-COMPACT-VIDEO-RENDERER:hover:not([is-dismissed]):not(:has(#dismissed-content)) #quick-actions,
  78. YTM-SHORTS-LOCKUP-VIEW-MODEL-V2:hover:not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
  79. YT-LOCKUP-VIEW-MODEL:hover:not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
  80. YTD-PLAYLIST-VIDEO-RENDERER:hover #quick-actions,
  81. YTD-VIDEO-RENDERER:hover #quick-actions,
  82. YTD-GRID-VIDEO-RENDERER:hover #quick-actions {
  83. display: flex;
  84. }
  85.  
  86. /*
  87. #dismissible:hover:not(:has(ytm-shorts-lockup-view-model-v2)) > #quick-actions {
  88. display: flex;
  89. }
  90. */
  91.  
  92. YT-LOCKUP-VIEW-MODEL:hover:has(#quick-actions),
  93. YTD-PLAYLIST-VIDEO-RENDERER:hover:has(#quick-actions) {
  94. position: relative;
  95. }
  96.  
  97. .fancy {
  98. -webkit-background-clip: text;
  99. -webkit-text-fill-color: transparent;
  100. background-image: linear-gradient(
  101. 45deg,
  102. var(--color-primary) 17%,
  103. var(--color-secondary) 100%
  104. );
  105. background-size: 400% auto;
  106. background-position: 0% 50%;
  107. animation: animate-gradient 12s linear infinite;
  108. font-weight: bold!important;
  109. }
  110.  
  111. @keyframes animate-gradient {
  112. 0% {
  113. background-position: 0% 50%;
  114. }
  115. 50% {
  116. background-position: 100% 50%;
  117. }
  118. 100% {
  119. background-position: 0% 50%;
  120. }
  121. }
  122. `;
  123.  
  124. GM_addStyle(style);
  125.  
  126. /* -------------------------------------------------------------------------- */
  127. /* Variables */
  128. /* -------------------------------------------------------------------------- */
  129.  
  130. // Elem to search for
  131. const normalVideoTagName = "YTD-RICH-ITEM-RENDERER";
  132. const searchVideoTagName = "YTD-VIDEO-RENDERER";
  133. const gridVideoTagName = "YTD-GRID-VIDEO-RENDERER";
  134. const compactVideoTagName = "YTD-COMPACT-VIDEO-RENDERER";
  135. const shortsV2VideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL-V2";
  136. const shortsVideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL";
  137. const compactPlaylistContainer = "YTD-ITEM-SECTION-RENDERER";
  138. const compactPlaylistSelector = ".yt-lockup-view-model-wiz";
  139. const playlistVideoTagName = "YT-LOCKUP-VIEW-MODEL";
  140. const playlistVideoTagName2 = "YTD-PLAYLIST-VIDEO-RENDERER";
  141. const memberVideoTagName = "YTD-MEMBERSHIP-BADGE-RENDERER";
  142. const memberVideoSelector = ".badge-style-type-members-only";
  143. const thumbnailElementSelector = "img.yt-core-image";
  144. const normalHamburgerMenuSelector = "button#button.style-scope.yt-icon-button";
  145. const shortsAndPlaylistHamburgerMenuSelector = "button.yt-spec-button-shape-next";
  146. const dropdownMenuTagName = "TP-YT-IRON-DROPDOWN";
  147. const popupMenuItemsSelector = "yt-formatted-string.style-scope.ytd-menu-service-item-renderer, yt-list-item-view-model[role='menuitem']";
  148. //Menu Extractions / Properties Path
  149. const searchMenuPropertyPath = "menu.menuRenderer.items";
  150. const gridMenuPropertyPath = "menu.menuRenderer.items";
  151. const shortsMenuPropertyPath = "content.shortsLockupViewModel.menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  152. const shortsV2MenuPropertyPath = "menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  153. const normalMenuPropertyPath = "content.videoRenderer.menu.menuRenderer.items";
  154. const playlistMenuPropertyPath = "content.lockupViewModel.metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  155. const playlistMenuPropertyPath2 = "menu.menuRenderer.items";
  156. const compactPlaylistMenuPropertyPath = "metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  157. const compactMenuPropertyPath = "menu.menuRenderer.items";
  158. const membersOnlyMenuPropertyPath = "content.feedEntryRenderer.item.videoRenderer.menu.menuRenderer.items";
  159. const membersOnlyMenuPropertyPath2 = "content.videoRenderer.menu.menuRenderer.items";
  160. const availableMenuItemsList1 = "listItemViewModel?.title?.content";
  161. const availableMenuItemsList2 = "menuServiceItemRenderer?.text?.runs?.[0]?.text";
  162. const normalVideoRichThumbnailPath = "content?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails?.[0]?.url";
  163. const normalVideoThumbnailPath = "content?.videoRenderer?.thumbnail?.thumbnails";
  164. const compactVideoRichThumbnailPath = "richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails?.[0]?.url";
  165. const compactVideoThumbnailPath = "thumbnail?.thumbnails";
  166.  
  167. // <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
  168. 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>`;
  169. 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>`;
  170. 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>`;
  171. 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>`;
  172. 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>`;
  173. 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>`;
  174. 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>`;
  175. 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>`;
  176.  
  177. /* -------------------------------------------------------------------------- */
  178. /* Functions */
  179. /* -------------------------------------------------------------------------- */
  180.  
  181. /* ----------------------------- Menu Commmands ----------------------------- */
  182.  
  183. let isLoggingEnabled = GM_getValue("isLoggingEnabled", false);
  184. let optRichThumbnail = GM_getValue("optRichThumbnail", true);
  185. const menuCommands = [
  186. {
  187. label: () => `Rich Thumbnail: ${optRichThumbnail ? "ON" : "OFF"}`,
  188. toggle: function toggleRichThumbnail()
  189. {
  190. optRichThumbnail = !optRichThumbnail;
  191. GM_setValue("optRichThumbnail", optRichThumbnail);
  192. updateMenuCommands();
  193. window.location.reload(true);
  194. },
  195. id: undefined,
  196. },
  197. {
  198. label: () => `Logging: ${isLoggingEnabled ? "ON" : "OFF"}`,
  199. toggle: function toggleLogging()
  200. {
  201. isLoggingEnabled = !isLoggingEnabled;
  202. GM_setValue("isLoggingEnabled", isLoggingEnabled);
  203. updateMenuCommands();
  204. window.location.reload(true);
  205. },
  206. id: undefined,
  207. }
  208. ];
  209.  
  210. function registerMenuCommands()
  211. {
  212. for (const command of menuCommands)
  213. {
  214. command.id = GM_registerMenuCommand(command.label(), command.toggle);
  215. }
  216. }
  217.  
  218. function updateMenuCommands()
  219. {
  220. for (const command of menuCommands)
  221. {
  222. if (command.id)
  223. {
  224. GM_unregisterMenuCommand(command.id);
  225. }
  226. command.id = GM_registerMenuCommand(command.label(), command.toggle);
  227. }
  228. }
  229.  
  230. function toggleRichThumbnail()
  231. {
  232. optRichThumbnail = !optRichThumbnail;
  233. GM_setValue("toggle5050Endorsement", optRichThumbnail);
  234. updateMenuCommands();
  235. window.location.reload(true);
  236. }
  237.  
  238. function toggleLogging()
  239. {
  240. isLoggingEnabled = !isLoggingEnabled;
  241. GM_setValue("isLoggingEnabled", isLoggingEnabled);
  242. updateMenuCommands();
  243. window.location.reload(true);
  244. }
  245.  
  246. registerMenuCommands();
  247.  
  248. /* ---------------------------- Menu Commands End --------------------------- */
  249.  
  250. function log(...args)
  251. {
  252. if (isLoggingEnabled)
  253. {
  254. console.log(...args);
  255. }
  256. }
  257.  
  258. function getByPathReduce(target, path)
  259. {
  260. return path.split('.').reduce((result, key) => result?.[key], target) ?? [];
  261. }
  262.  
  263. //Same result as getByPathReduce()
  264. function getByPathFunction(object, path)
  265. {
  266. try
  267. {
  268. return new Function('object', `return object.${path}`)(object) ?? [];
  269. } catch
  270. {
  271. return [];
  272. }
  273. }
  274.  
  275. function getDataProperty(origin, videoType)
  276. {
  277. const childQuerySelectors = {
  278. "shorts-v2": shortsVideoTagName,
  279. "compact-playlist": compactPlaylistSelector,
  280. };
  281. const selector = childQuerySelectors[videoType];
  282. const target = selector ? origin.querySelector(selector) : origin;
  283. return target?.data;
  284. }
  285.  
  286. function getMenuList(target)
  287. {
  288. return target.map(item =>
  289. {
  290. const first = getByPathFunction(item, availableMenuItemsList1);
  291. if (first.length) return first;
  292.  
  293. const second = getByPathFunction(item, availableMenuItemsList2);
  294. if (second.length) return second;
  295.  
  296. return null;
  297. }).filter(Boolean);
  298. }
  299.  
  300. function findElemInParentDomTree(originElem, targetSelector)
  301. {
  302. log(`🔍 Starting search from:`, originElem);
  303.  
  304. let node = originElem;
  305. while (node)
  306. {
  307. log(`👆 Checking ancestor:`, node);
  308. const found = Array.from(node.children).find(
  309. (child) => child.matches(targetSelector) || child.querySelector(targetSelector)
  310. );
  311.  
  312. if (found)
  313. {
  314. const result = found.matches(targetSelector) ? found : found.querySelector(targetSelector);
  315. log(`✅ Found target:`, result);
  316. return result;
  317. }
  318.  
  319. node = node.parentElement;
  320. }
  321.  
  322. log("⚠️ No matching element found.");
  323. return null;
  324. }
  325.  
  326. function getVisibleElem(targetSelector)
  327. {
  328. const elements = document.querySelectorAll(targetSelector);
  329. for (const element of elements)
  330. {
  331. const rect = element.getBoundingClientRect();
  332. if (element.offsetParent !== null && rect.width > 0 && rect.height > 0)
  333. {
  334. log("👀 Menu is visible and ready:", element);
  335. return element;
  336. }
  337. }
  338. log("⚠️ No visible menu found.");
  339. return null;
  340. }
  341.  
  342. async function waitUntil(conditionFunction, { interval = 100, timeout = 3000 } = {})
  343. {
  344. const startTime = Date.now();
  345. while (Date.now() - startTime < timeout)
  346. {
  347. const result = conditionFunction();
  348. if (result) return result;
  349. await new Promise((resolve) => setTimeout(resolve, interval));
  350. }
  351. throw new Error("⏰ Timeout: Target element is not visible in time");
  352. }
  353.  
  354. function retryClick(element, { maxAttempts = 5, interval = 300 } = {})
  355. {
  356. return new Promise((resolve) =>
  357. {
  358. let attempts = 0;
  359.  
  360. function tryClick()
  361. {
  362. if (!element || attempts >= maxAttempts)
  363. {
  364. log("⚠️ Retry failed or element missing.");
  365. return resolve();
  366. }
  367.  
  368. const rect = element.getBoundingClientRect();
  369. const isVisible = rect.width > 0 && rect.height > 0;
  370.  
  371. if (isVisible)
  372. {
  373. element.dispatchEvent(
  374. new MouseEvent("click", {
  375. view: document.defaultView,
  376. bubbles: true,
  377. cancelable: true,
  378. }),
  379. );
  380. log("👇 Clicked matching menu item");
  381. return resolve();
  382. } else
  383. {
  384. attempts++;
  385. setTimeout(tryClick, interval);
  386. }
  387. }
  388.  
  389. tryClick();
  390. });
  391. }
  392.  
  393. function appendButtons(element, menuItems, type, position)
  394. {
  395. let className, titleText, icon;
  396. let buttonsToAppend = [];
  397.  
  398. const finalMenuItems = [...new Set(menuItems)];
  399.  
  400. //If menu is empty, proceed and still append the container to prevent looping of menu data probe.
  401. //Probe will only skip if #quick-action exist.
  402.  
  403. for (const item of finalMenuItems)
  404. {
  405. if (!item) continue;
  406.  
  407. let className;
  408. let titleText;
  409. let icon;
  410.  
  411. if (item.startsWith("Remove from "))
  412. {
  413. className = "remove";
  414. titleText = "Remove from playlist";
  415. icon = trashIcon;
  416. } else
  417. {
  418. switch (item)
  419. {
  420. case "Not interested":
  421. className = "not_interested";
  422. titleText = "Not interested";
  423. icon = notInterestedIcon;
  424. break;
  425. case "Don't recommend channel":
  426. className = "dont_recommend_channel";
  427. titleText = "Don't recommend channel";
  428. icon = dontRecommendChannelIcon;
  429. break;
  430. case "Hide":
  431. className = "hide";
  432. titleText = "Hide video";
  433. icon = hideIcon;
  434. break;
  435. case "Save to playlist":
  436. className = "save";
  437. titleText = "Save to playlist";
  438. icon = saveIcon;
  439. break;
  440. default:
  441. continue;
  442. }
  443. }
  444.  
  445. buttonsToAppend.push(
  446. `<button class="qa-button ${className}" data-icon="${className}" title="${titleText}" data-text="${titleText}">${icon}</button>`,
  447. );
  448. }
  449.  
  450. const buttonsContainer = document.createElement("div");
  451. buttonsContainer.id = "quick-actions";
  452. buttonsContainer.classList.add(position, type);
  453. buttonsContainer.innerHTML = buttonsToAppend.join("");
  454.  
  455. //element.insertAdjacentElement("afterend", buttonsContainer);
  456. const exist = element.querySelector("#quick-actions");
  457. if (exist) return;
  458. element.insertAdjacentElement("beforeend", buttonsContainer);
  459. }
  460.  
  461. function onPageChange(callback)
  462. {
  463. const listenerMap = new Map();
  464. ['pushState', 'replaceState'].forEach(method =>
  465. {
  466. const original = history[method];
  467. const wrapped = function (...args)
  468. {
  469. const result = original.apply(this, args);
  470. window.dispatchEvent(new Event('spa-route-change'));
  471. return result;
  472. };
  473.  
  474. history[method] = wrapped;
  475. listenerMap.set(method, original);
  476. });
  477.  
  478. const onSpaRouteChange = () => callback('spa', window.location.href);
  479. const onPopState = () => window.dispatchEvent(new Event('spa-route-change'));
  480. const onYtAction = (event) =>
  481. {
  482. const actionName = event?.detail?.actionName;
  483. if (actionName === 'yt-history-pop' || actionName === 'yt-navigate')
  484. {
  485. callback('yt', window.location.href);
  486. }
  487. };
  488.  
  489. window.addEventListener('spa-route-change', onSpaRouteChange);
  490. window.addEventListener('popstate', onPopState);
  491. document.addEventListener('yt-action', onYtAction);
  492.  
  493. return function cleanup()
  494. {
  495. for (const [method, original] of listenerMap.entries())
  496. {
  497. history[method] = original;
  498. }
  499.  
  500. window.removeEventListener('spa-route-change', onSpaRouteChange);
  501. window.removeEventListener('popstate', onPopState);
  502. document.removeEventListener('yt-action', onYtAction);
  503. };
  504. }
  505.  
  506. /* -------------------------------------------------------------------------- */
  507. /* Listeners */
  508. /* -------------------------------------------------------------------------- */
  509.  
  510. // Remove all existing quick-action elements. On certain pages, like channel tabs, content is updated in-place
  511. // without removing the grid/container. If not cleared, old quick-action buttons will remain attached to unrelated items.
  512. // This ensures that if the content is updated, new hover actions will fetch fresh, relevant data.
  513. // I have not take a closer look at yt-made events. propably have some things we can customized and fire to speed things up
  514. // skip querying and fired the action straight up via their internal events
  515.  
  516. let richThumbnailDisabler = new Date(Date.now() + 360 * 60 * 1000);
  517.  
  518. onPageChange((source, url) =>
  519. {
  520. richThumbnailDisabler = new Date(Date.now() + 360 * 60 * 1000);
  521. });
  522.  
  523. document.addEventListener("yt-action", (event) =>
  524. {
  525. if (event.detail.actionName === "ytd-update-grid-state-action")
  526. {
  527. log("🐛 Page updated.");
  528. document.querySelectorAll("#quick-actions").forEach((element) => element.remove());
  529. }
  530. });
  531.  
  532. let opThumbnail, riThumbnail;
  533. document.addEventListener("mouseover", (event) =>
  534. {
  535. const path = event.composedPath();
  536. for (let element of path)
  537. {
  538. if (
  539. (element.tagName === normalVideoTagName ||
  540. element.tagName === compactVideoTagName ||
  541. element.tagName === shortsV2VideoTagName ||
  542. element.tagName === searchVideoTagName ||
  543. element.tagName === gridVideoTagName ||
  544. element.tagName === playlistVideoTagName ||
  545. element.tagName === playlistVideoTagName2) &&
  546. !element.querySelector("#quick-actions")
  547. )
  548. {
  549. let type, data;
  550.  
  551. // Determine element type
  552. // Hierarchy might need tweaking to simplify detection. nah this whole listener block,
  553. // cause i'm already confused which tag is needed for which video, what need extra query, then which path
  554. // and specific video type wont get shown unless specific step is done, even then rarely replicable to debug
  555. // some of this type no longer valid as i go, cause i can't keep track no more
  556. if (element.tagName === shortsV2VideoTagName)
  557. {
  558. type = "shorts-v2";
  559. }
  560. else if (element.tagName === playlistVideoTagName && element.parentElement.parentElement.tagName === compactPlaylistContainer)
  561. {
  562. type = "compact-playlist";
  563. }
  564. else if (element.tagName === gridVideoTagName)
  565. {
  566. type = "grid-video";
  567. }
  568. else if (element.tagName === searchVideoTagName)
  569. {
  570. type = "search-video";
  571. }
  572. else if (element.tagName === playlistVideoTagName && element.parentElement.parentElement.tagName === normalVideoTagName)
  573. {
  574. //hover listener will land on playlistVideoTagName instead of normalVideoTagName for playlist/mixes on homepage
  575. //so manually change back to normalVideoTagName as data is there.
  576. element = element.parentElement.parentElement;
  577. if (element.querySelector("#quick-actions")) return;
  578. type = "playlist";
  579. }
  580. else if (element.tagName === playlistVideoTagName2)
  581. {
  582. type = "playlist2";
  583. }
  584. else
  585. {
  586. const isShort = element.querySelector(shortsVideoTagName) !== null;
  587. const isPlaylist = element.querySelector(playlistVideoTagName) !== null;
  588. const isMemberOnly =
  589. element.querySelector(memberVideoTagName) !== null ||
  590. element.querySelector(memberVideoSelector) !== null;
  591.  
  592. type = isShort ? "shorts" :
  593. element.tagName === compactVideoTagName ? "compact" :
  594. isPlaylist ? "collection" :
  595. isMemberOnly ? "members_only" :
  596. "normal";
  597. }
  598.  
  599. log("⭐ Video Elem: ", element.tagName, element);
  600. log("ℹ️ Video Type: ", type);
  601.  
  602. data = getDataProperty(element, type);
  603. const thumbnailElement = element.querySelector(thumbnailElementSelector);
  604. const thumbnailSize =
  605. thumbnailElement?.getClientRects?.().length > 0
  606. ? parseInt(thumbnailElement.getClientRects()[0].width)
  607. : 100;
  608. log("🖼️ Thumbnail Size: ", thumbnailSize);
  609. const containerPosition = thumbnailSize < 211 ? "location-02" : "location-01";
  610.  
  611. if (!data)
  612. {
  613. log("⚠️ No props data found.");
  614. return;
  615. }
  616.  
  617. log("🎥 Video Props: ", data);
  618.  
  619. let menulist;
  620. switch (type)
  621. {
  622. case "normal":
  623. menulist = getByPathFunction(data, normalMenuPropertyPath);
  624. break;
  625. case "search-video":
  626. menulist = getByPathFunction(data, searchMenuPropertyPath);
  627. break;
  628. case "grid-video":
  629. menulist = getByPathFunction(data, gridMenuPropertyPath);
  630. break;
  631. case "shorts":
  632. menulist = getByPathFunction(data, shortsMenuPropertyPath);
  633. break;
  634. case "shorts-v2":
  635. menulist = getByPathFunction(data, shortsV2MenuPropertyPath);
  636. break;
  637. case "compact":
  638. menulist = getByPathFunction(data, compactMenuPropertyPath);
  639. break;
  640. case "collection":
  641. menulist = getByPathFunction(data, playlistMenuPropertyPath);
  642. break;
  643. case "playlist":
  644. menulist = getByPathFunction(data, playlistMenuPropertyPath);
  645. break;
  646. case "playlist2":
  647. menulist = getByPathFunction(data, playlistMenuPropertyPath2);
  648. break;
  649. case "compact-playlist":
  650. menulist = getByPathFunction(data, compactPlaylistMenuPropertyPath);
  651. break;
  652. case "members_only":
  653. menulist = getByPathFunction(data, membersOnlyMenuPropertyPath);
  654. if (!menulist.length)
  655. {
  656. menulist = getByPathFunction(data, membersOnlyMenuPropertyPath2);
  657. }
  658. break;
  659. default:
  660. menulist = getByPathFunction(data, normalMenuPropertyPath);
  661. break;
  662. }
  663.  
  664. const menulistItems = getMenuList(menulist);
  665. log("📃 Menu items: ", menulistItems);
  666. appendButtons(element, menulistItems, type, containerPosition);
  667.  
  668. //Rich Thumbnails
  669. //Rich thumbnail is hardcoded on dataset and expired after 6 hours therefore
  670. //we'll disable it after 6 hours from page first loaded.
  671. //on error check is also added to prevent gray default to be added if rich thumbnail is expired
  672. if (optRichThumbnail && Date.now() < richThumbnailDisabler)
  673. {
  674. log("📸 Rich Thumbnails: ", Date.now() < richThumbnailDisabler);
  675. let hoverToken = null;
  676. const mouseOverHandler = async (event) =>
  677. {
  678. const token = Symbol();
  679. hoverToken = token;
  680.  
  681. const currentThumbnail = element.querySelector("img.yt-core-image");
  682. const thumbailData = getDataProperty(element, type);
  683. const normalRichThumbnail = getByPathFunction(thumbailData, normalVideoRichThumbnailPath);
  684. const compactRichThumbnail = getByPathFunction(thumbailData, compactVideoRichThumbnailPath);
  685. const richThumbnail =
  686. (typeof normalRichThumbnail === 'string' && normalRichThumbnail) ||
  687. (typeof compactRichThumbnail === 'string' && compactRichThumbnail) ||
  688. undefined;
  689.  
  690. function isImageValid(url)
  691. {
  692. return new Promise((resolve) =>
  693. {
  694. const img = new Image();
  695. img.onload = () => resolve(true);
  696. img.onerror = () => resolve(false);
  697. img.src = url;
  698. });
  699. }
  700.  
  701. const isValid = await isImageValid(richThumbnail);
  702.  
  703. if (richThumbnail && isValid && hoverToken === token)
  704. {
  705. currentThumbnail.src = richThumbnail;
  706. }
  707. };
  708.  
  709. const mouseOutHandler = (event) =>
  710. {
  711. hoverToken = null;
  712. const currentThumbnail = element.querySelector("img.yt-core-image");
  713. const thumbnailData = getDataProperty(element, type);
  714. const normalThumbnails = getByPathFunction(thumbnailData, normalVideoThumbnailPath);
  715. const compactThumbnails = getByPathFunction(thumbnailData, compactVideoThumbnailPath);
  716. const biggestNormalThumbnail = normalThumbnails.at(-1)?.url;
  717. const biggestCompactThumbnail = compactThumbnails.at(-1)?.url;
  718. const staticThumbnail = biggestNormalThumbnail || biggestCompactThumbnail;
  719.  
  720. if (staticThumbnail)
  721. {
  722. currentThumbnail.src = staticThumbnail;
  723. }
  724. };
  725.  
  726. element.addEventListener("mouseenter", mouseOverHandler, true);
  727. element.addEventListener("mouseleave", mouseOutHandler, true);
  728.  
  729. setTimeout(() =>
  730. {
  731. element.removeEventListener("mouseover", mouseOverHandler, true);
  732. element.removeEventListener("mouseout", mouseOutHandler, true);
  733. log("📸 Rich Thumbnails: disabled after timeout");
  734. }, richThumbnailDisabler - Date.now());
  735. }
  736. }
  737. }
  738. }, true);
  739.  
  740. document.addEventListener("click", async function (event)
  741. {
  742. const button = event.target.closest(".qa-button");
  743. if (!button) return;
  744.  
  745. event.stopPropagation();
  746. event.stopImmediatePropagation();
  747. event.preventDefault();
  748.  
  749. const actionType = button.dataset.icon;
  750. let response;
  751.  
  752. switch (actionType)
  753. {
  754. case "not_interested":
  755. response = "Not interested";
  756. log("😴 Marking as not interested");
  757. break;
  758. case "dont_recommend_channel":
  759. response = "Don't recommend channel";
  760. log("🚫 Don't recommend channel");
  761. break;
  762. case "hide":
  763. response = "Hide";
  764. log("🗑️ Hiding video");
  765. break;
  766. case "remove":
  767. response = "Remove from";
  768. log("🗑️ Remove from playlist");
  769. break;
  770. case "save":
  771. response = "Save to playlist";
  772. log("📂 Saving to playlist");
  773. break;
  774. default:
  775. log("☠️ Unknown action");
  776. }
  777.  
  778. let menupath;
  779.  
  780. if (button.parentElement.parentElement.tagName === shortsV2VideoTagName || button.parentElement.parentElement.querySelector(playlistVideoTagName))
  781. {
  782. menupath = shortsAndPlaylistHamburgerMenuSelector;
  783. }
  784. else if (button.parentElement.classList.contains("shorts"))
  785. {
  786. //shorts but not inside shortsv2 container idk where i found this its gone now crazy i was crazy once
  787. //been a while, probably safe to remove now.
  788. alert("shorts!");
  789. menupath = shortsAndPlaylistHamburgerMenuSelector;
  790. }
  791. else if (button.parentElement.classList.contains("compact-playlist"))
  792. {
  793. menupath = shortsAndPlaylistHamburgerMenuSelector;
  794. }
  795. else
  796. {
  797. menupath = normalHamburgerMenuSelector;
  798. }
  799.  
  800. const menus = findElemInParentDomTree(button, menupath);
  801. if (!menus)
  802. {
  803. log("❌ Menu button not found.");
  804. return;
  805. }
  806.  
  807. menus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  808. log("👇 Button clicked, waiting for menu...");
  809.  
  810. try
  811. {
  812. const visibleMenu = await waitUntil(() => getVisibleElem(dropdownMenuTagName), {
  813. interval: 100,
  814. timeout: 3000,
  815. });
  816. if (visibleMenu)
  817. {
  818. try
  819. {
  820. const targetItem = await waitUntil(
  821. () =>
  822. {
  823. const items = visibleMenu.querySelectorAll(popupMenuItemsSelector);
  824. return items.length > 0 ? items : null;
  825. },
  826. {
  827. interval: 100,
  828. timeout: 5000,
  829. },
  830. );
  831.  
  832. if (targetItem)
  833. {
  834. log("🎉 Target items found:", targetItem);
  835.  
  836. for (const item of targetItem)
  837. {
  838. if (
  839. item.textContent === response ||
  840. (response === "Remove from" && item.textContent.startsWith("Remove from"))
  841. )
  842. {
  843. log(`✅ Matched: (${response} = ${item.textContent})`);
  844. log(`✅`, item);
  845.  
  846. const button = item;
  847. await retryClick(button, { maxAttempts: 5, interval: 300 }).finally(() =>
  848. {
  849. document.body.click();
  850. });
  851. break;
  852. } else
  853. {
  854. log(`❌ Not a match: (${response} = ${item.textContent})`);
  855. }
  856. }
  857. }
  858. } catch (error)
  859. {
  860. log("🛑 !", error.message);
  861. //document.body.click()
  862. }
  863. }
  864.  
  865. //setTimeout(() => document.body.click(), 200);
  866. } catch (error)
  867. {
  868. log("🛑 !!", error.message);
  869. //document.body.click()
  870. }
  871. });
  872.  
  873. /* -------------------------------------------------------------------------- */
  874. /* This script is brought to you in support of FIFTY FIFTY 💖 */
  875. /* -------------------------------------------------------------------------- */
  876.  
  877. if ("💖")
  878. {
  879. const selectorsToWatch = ['a', 'yt-formatted-string'];
  880. const observedElements = new WeakMap();
  881.  
  882. function observeTextContentChanges(element)
  883. {
  884. if (observedElements.has(element)) return;
  885.  
  886. const elementObserver = new MutationObserver(() =>
  887. {
  888. const hasText = element.textContent.trim() === "FIFTY FIFTY Official";
  889. element.classList.toggle("fancy", hasText);
  890. });
  891.  
  892. observedElements.set(element, elementObserver);
  893. elementObserver.observe(element, { characterData: true, childList: true, subtree: true });
  894. }
  895.  
  896. document.querySelectorAll(selectorsToWatch.join(',')).forEach(element =>
  897. {
  898. if (element.textContent.trim() === "FIFTY FIFTY Official") element.classList.add("fancy");
  899. observeTextContentChanges(element);
  900. });
  901.  
  902. const observer = new MutationObserver((mutations) =>
  903. {
  904. for (const mutation of mutations)
  905. {
  906. for (const node of mutation.addedNodes)
  907. {
  908. if (node.nodeType !== 1) continue;
  909.  
  910. for (const selector of selectorsToWatch)
  911. {
  912. const elements = node.matches(selector) ? [node] : node.querySelectorAll(selector);
  913. elements.forEach(element =>
  914. {
  915. if (element.textContent.trim() === "FIFTY FIFTY Official") element.classList.add("fancy");
  916. observeTextContentChanges(element);
  917. });
  918. }
  919. }
  920.  
  921. for (const node of mutation.removedNodes)
  922. {
  923. if (node.nodeType === 1 && observedElements.has(node))
  924. {
  925. observedElements.get(node).disconnect();
  926. observedElements.delete(node);
  927. }
  928. }
  929. }
  930. });
  931.  
  932. observer.observe(document.body, { childList: true, subtree: true });
  933. }