YouTube Quick Actions

QUICK BOII

当前为 2025-04-21 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Quick Actions
  3. // @description QUICK BOII
  4. // @version 1.0.0
  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. // @compatible firefox
  10. // @namespace https://greasyfork.org/users/1223791
  11. // ==/UserScript==
  12.  
  13. "use strict";
  14.  
  15. console.log("🫡 [Youtube Quick Actions] Script initialized");
  16.  
  17. const css = String.raw;
  18. const style = css`
  19. #quick-actions {
  20. position: absolute;
  21. display: none;
  22. flex-direction: column;
  23. gap: 0.2em;
  24. align-items: flex-start;
  25. }
  26.  
  27. .location-01 {
  28. top: 0.8em;
  29. left: 0.8em;
  30. }
  31.  
  32. .location-02 {
  33. top: 0.4em;
  34. left: 0.4em;
  35. }
  36.  
  37. .qa-button {
  38. background-color: rgba(0, 0, 0, 0.9);
  39. /* box-shadow: inset 2px 3px 5px #000, 0px 0px 8px #d0d0d02e; */
  40. z-index: 1000;
  41. border: 1px solid #f0f0f05c;
  42. width: 26px;
  43. height: 26px;
  44. display: flex;
  45. justify-content: center;
  46. align-items: center;
  47. color: white;
  48. font-size: 16px;
  49. font-weight: bold;
  50. border-radius: 4px;
  51. cursor: pointer;
  52. flex-shrink: unset;
  53. }
  54.  
  55. .qa-button:hover {
  56. border: 1px solid rgba(255, 255, 255, 0.2);
  57. opacity: 0.9;
  58. background-color: rgba(55, 55, 55, 0.9);
  59. }
  60.  
  61. .qa-icon {
  62. width: 1em;
  63. height: 1em;
  64. vertical-align: -0.125em;
  65. }
  66.  
  67. YTD-RICH-ITEM-RENDERER:hover:not(:has(ytd-rich-grid-media[is-dismissed])):not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
  68. YTD-COMPACT-VIDEO-RENDERER:hover:not([is-dismissed]):not(:has(#dismissed-content)) #quick-actions {
  69. display: flex;
  70. }
  71. /*
  72. #dismissible:hover:not(:has(ytm-shorts-lockup-view-model-v2)) > #quick-actions {
  73. display: flex;
  74. }
  75. */
  76. ytm-shorts-lockup-view-model-v2:hover:not(:has(.ytDismissibleItemReplacedContent)) #quick-actions {
  77. display: flex;
  78. }
  79.  
  80. .xhover {
  81. display: flex;!important
  82. }
  83.  
  84. .xhide {
  85. display: none;
  86. }
  87. `;
  88.  
  89. GM_addStyle(style);
  90.  
  91. /* -------------------------------------------------------------------------- */
  92. /* Variables */
  93. /* -------------------------------------------------------------------------- */
  94.  
  95. let isLoggingEnabled = false;
  96.  
  97. // Elem to search for
  98. const normalVideoTagName = "YTD-RICH-ITEM-RENDERER";
  99. const compactVideoTagName = "YTD-COMPACT-VIDEO-RENDERER";
  100. const shortsV2VideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL-V2";
  101. const shortsVideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL";
  102. const playlistVideoTagName = "YT-LOCKUP-VIEW-MODEL";
  103. const memberVideoTagName = "YTD-MEMBERSHIP-BADGE-RENDERER";
  104. const thumbnailElementSelector = "img.yt-core-image";
  105. const normalHamburgerMenuSelector = "button#button.style-scope.yt-icon-button";
  106. const shortsAndPlaylistHamburgerMenuSelector = "button.yt-spec-button-shape-next";
  107. const dropdownMenuSelector = "TP-YT-IRON-DROPDOWN";
  108. const popupMenuItemsSelector = "yt-formatted-string.style-scope.ytd-menu-service-item-renderer, yt-list-item-view-model[role='menuitem']";
  109.  
  110. //Menu Extractions / Properties Path
  111. const shortsMenuPropertyPath = "content.shortsLockupViewModel.menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  112. const shortsV2MenuPropertyPath = "menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  113. const normalMenuPropertyPath = "content.videoRenderer.menu.menuRenderer.items";
  114. const playlistMenuPropertyPath = "content.lockupViewModel.metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
  115. const compactMenuPropertyPath = "menu.menuRenderer.items";
  116. const membersOnlyMenuPropertyPath = "content.feedEntryRenderer.item.videoRenderer.menu.menuRenderer.items";
  117. const availableMenuItemsList1 = "listItemViewModel?.title?.content";
  118. const availableMenuItemsList2 = "menuServiceItemRenderer?.text?.runs?.[0]?.text";
  119.  
  120. //Icons = Font Awesome by @fontawesome - https://fontawesome.com */
  121. const notInterestedIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><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>`;
  122. const saveIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><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>`;
  123. const dontRecommendChannelIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><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>`;
  124. const hideIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor"><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>`;
  125.  
  126.  
  127. /* -------------------------------------------------------------------------- */
  128. /* Functions */
  129. /* -------------------------------------------------------------------------- */
  130.  
  131. function log(...args)
  132. {
  133. if (isLoggingEnabled)
  134. {
  135. console.log(...args);
  136. }
  137. }
  138.  
  139.  
  140. function getByPathReduce(target, path)
  141. {
  142. return path.split('.').reduce((result, key) => result?.[key], target) ?? [];
  143. }
  144.  
  145. //Same result as getByPathReduce()
  146. function getByPathFunction(object, path)
  147. {
  148. try
  149. {
  150. return new Function('object', `return object.${path}`)(object) ?? [];
  151. } catch
  152. {
  153. return [];
  154. }
  155. }
  156.  
  157. function getDataProperty(origin, videoType)
  158. {
  159. const childQuerySelectors = {
  160. "shorts-v2": shortsVideoTagName,
  161.  
  162. };
  163. const selector = childQuerySelectors[videoType];
  164. const target = selector ? origin.querySelector(selector) : origin;
  165. return target?.data;
  166. }
  167.  
  168. function getMenuList(target)
  169. {
  170. return target.map(item =>
  171. {
  172. const first = getByPathFunction(item, availableMenuItemsList1);
  173. if (first.length) return first;
  174.  
  175. const second = getByPathFunction(item, availableMenuItemsList2);
  176. if (second.length) return second;
  177.  
  178. return null;
  179. }).filter(Boolean);
  180. }
  181.  
  182. function findElemInParentDomTree(originElem, targetSelector)
  183. {
  184. log(`🔍 Starting search from:`, originElem);
  185.  
  186. let node = originElem;
  187. while (node)
  188. {
  189. log(`👆 Checking ancestor:`, node);
  190. const found = Array.from(node.children).find(
  191. (child) => child.matches(targetSelector) || child.querySelector(targetSelector)
  192. );
  193.  
  194. if (found)
  195. {
  196. const result = found.matches(targetSelector) ? found : found.querySelector(targetSelector);
  197. log(`✅ Found target:`, result);
  198. return result;
  199. }
  200.  
  201. node = node.parentElement;
  202. }
  203.  
  204. log("⚠️ No matching element found.");
  205. return null;
  206. }
  207.  
  208. function getVisibleElem(targetSelector)
  209. {
  210. const elements = document.querySelectorAll(targetSelector);
  211. for (const element of elements)
  212. {
  213. const rect = element.getBoundingClientRect();
  214. if (element.offsetParent !== null && rect.width > 0 && rect.height > 0)
  215. {
  216. log("👀 Menu is visible and ready:", element);
  217. return element;
  218. }
  219. }
  220. log("⚠️ No visible menu found.");
  221. return null;
  222. }
  223.  
  224. async function waitUntil(conditionFunction, { interval = 100, timeout = 3000 } = {})
  225. {
  226. const startTime = Date.now();
  227. while (Date.now() - startTime < timeout)
  228. {
  229. const result = conditionFunction();
  230. if (result) return result;
  231. await new Promise((resolve) => setTimeout(resolve, interval));
  232. }
  233. throw new Error("⏰ Timeout: Target element is not visible in time");
  234. }
  235.  
  236. function retryClick(element, { maxAttempts = 5, interval = 300 } = {})
  237. {
  238. return new Promise((resolve) =>
  239. {
  240. let attempts = 0;
  241.  
  242. function tryClick()
  243. {
  244. if (!element || attempts >= maxAttempts)
  245. {
  246. log("⚠️ Retry failed or element missing.");
  247. return resolve();
  248. }
  249.  
  250. const rect = element.getBoundingClientRect();
  251. const isVisible = rect.width > 0 && rect.height > 0;
  252.  
  253. if (isVisible)
  254. {
  255. element.dispatchEvent(
  256. new MouseEvent("click", {
  257. view: document.defaultView,
  258. bubbles: true,
  259. cancelable: true,
  260. }),
  261. );
  262. log("👇 Clicked matching menu item");
  263. return resolve();
  264. } else
  265. {
  266. attempts++;
  267. setTimeout(tryClick, interval);
  268. }
  269. }
  270.  
  271. tryClick();
  272. });
  273. }
  274.  
  275.  
  276. function appendButtons(element, menuItems, type, position)
  277. {
  278. let className, titleText, icon;
  279. let buttonsToAppend = [];
  280.  
  281. const finalMenuItems = [...new Set(menuItems)];
  282.  
  283. for (const item of finalMenuItems)
  284. {
  285. if (!item) continue;
  286.  
  287. switch (item)
  288. {
  289. case "Not interested":
  290. className = "not_interested";
  291. titleText = "Not interested";
  292. icon = notInterestedIcon;
  293. break;
  294. case "Don't recommend channel":
  295. className = "dont_recommend_channel";
  296. titleText = "Don't recommend channel";
  297. icon = dontRecommendChannelIcon;
  298. break;
  299. case "Hide":
  300. className = "hide";
  301. titleText = "Hide video";
  302. icon = hideIcon;
  303. break;
  304. case "Save to playlist":
  305. className = "save";
  306. titleText = "Save to playlist";
  307. icon = saveIcon;
  308. break;
  309. default:
  310. continue;
  311. }
  312.  
  313. buttonsToAppend.push(
  314. `<button class="qa-button ${className}" data-icon="${className}" title="${titleText}" data-text="${titleText}">${icon}</button>`,
  315. );
  316. }
  317.  
  318. const buttonsContainer = document.createElement("div");
  319. buttonsContainer.id = "quick-actions";
  320. buttonsContainer.classList.add(position, type);
  321. buttonsContainer.innerHTML = buttonsToAppend.join("");
  322.  
  323. //element.insertAdjacentElement("afterend", buttonsContainer);
  324. element.insertAdjacentElement("beforeend", buttonsContainer);
  325. }
  326.  
  327. /* -------------------------------------------------------------------------- */
  328. /* Listeners */
  329. /* -------------------------------------------------------------------------- */
  330.  
  331. document.addEventListener("mouseover", (event) =>
  332. {
  333. const path = event.composedPath();
  334. for (let element of path)
  335. {
  336. if (
  337. (element.tagName === normalVideoTagName ||
  338. element.tagName === compactVideoTagName ||
  339. element.tagName === shortsV2VideoTagName) &&
  340. !element.querySelector("#quick-actions")
  341. )
  342. {
  343. let type, data;
  344.  
  345. // Determine element type
  346. if (element.tagName === shortsV2VideoTagName)
  347. {
  348. type = "shorts-v2";
  349. } else
  350. {
  351. const isShort = element.querySelector(shortsVideoTagName) !== null;
  352. const isPlaylist = element.querySelector(playlistVideoTagName) !== null;
  353. const isMemberOnly = element.querySelector(memberVideoTagName) !== null;
  354.  
  355. type = isShort ? "shorts" :
  356. element.tagName === compactVideoTagName ? "compact" :
  357. isPlaylist ? "collection" :
  358. isMemberOnly ? "members_only" :
  359. "normal";
  360. }
  361.  
  362. data = getDataProperty(element, type);
  363.  
  364. log("ℹ️ Video Type: ", type);
  365. const thumbnailElement = element.querySelector(thumbnailElementSelector);
  366. const thumbnailSize = parseInt(thumbnailElement.getClientRects("width")[0].width) || 100;
  367. log("🖼️ Thumbnail Size: ", thumbnailSize);
  368. const containerPosition = thumbnailSize < 200 ? "location-02" : "location-01";
  369.  
  370. if (!data)
  371. {
  372. log("⚠️ No props data found.");
  373. return;
  374. }
  375.  
  376. log("🎥 Video Props: ", data);
  377.  
  378. // Process menus based on video type
  379. let menulist;
  380. switch (type)
  381. {
  382. case "normal":
  383. menulist = getByPathFunction(data, normalMenuPropertyPath);
  384. break;
  385. case "shorts":
  386. menulist = getByPathFunction(data, shortsMenuPropertyPath);
  387. break;
  388. case "shorts-v2":
  389. menulist = getByPathFunction(data, shortsV2MenuPropertyPath);
  390. break;
  391. case "compact":
  392. menulist = getByPathFunction(data, compactMenuPropertyPath);
  393. break;
  394. case "collection":
  395. menulist = getByPathFunction(data, playlistMenuPropertyPath);
  396. break;
  397. case "members_only":
  398. menulist = getByPathFunction(data, membersOnlyMenuPropertyPath);
  399. break;
  400. default:
  401. menulist = getByPathFunction(data, normalMenuPropertyPath);
  402. break;
  403. }
  404.  
  405.  
  406. const menulistItems = getMenuList(menulist);
  407. log("📃 Menu items: ", menulistItems);
  408. appendButtons(element, menulistItems, type, containerPosition);
  409. }
  410. }
  411. }, true);
  412.  
  413. document.addEventListener("click", async function (event)
  414. {
  415. const button = event.target.closest(".qa-button");
  416. if (!button) return;
  417.  
  418. event.stopPropagation();
  419. event.stopImmediatePropagation();
  420. event.preventDefault();
  421.  
  422. const actionType = button.dataset.icon;
  423. let response;
  424.  
  425. switch (actionType)
  426. {
  427. case "not_interested":
  428. response = "Not interested";
  429. log("😴 Marking as not interested");
  430. break;
  431. case "dont_recommend_channel":
  432. response = "Don't recommend channel";
  433. log("🚫 Don't recommend channel");
  434. break;
  435. case "hide":
  436. response = "Hide";
  437. log("🗑️ Hiding video");
  438. break;
  439. case "save":
  440. response = "Save to playlist";
  441. log("📂 Saving to playlist");
  442. break;
  443. default:
  444. log("☠️ Unknown action");
  445. }
  446.  
  447. let menupath;
  448.  
  449. if (button.parentElement.parentElement.tagName === shortsV2VideoTagName || button.parentElement.parentElement.querySelector(playlistVideoTagName))
  450. {
  451. menupath = shortsAndPlaylistHamburgerMenuSelector;
  452. }
  453. else if (button.parentElement.classList.contains("shorts"))
  454. {
  455. //shorts but not inside shortsv2 container idk where i found this its gone now crazy i was crazy once
  456. alert("shorts!");
  457. menupath = null;
  458. }
  459. else
  460. {
  461. menupath = normalHamburgerMenuSelector;
  462. }
  463. const menus = findElemInParentDomTree(button, menupath);
  464. if (!menus)
  465. {
  466. log("❌ Menu button not found.");
  467. return;
  468. }
  469.  
  470. menus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  471. log("👇 Button clicked, waiting for menu...");
  472.  
  473. try
  474. {
  475. const visibleMenu = await waitUntil(() => getVisibleElem(dropdownMenuSelector), {
  476. interval: 100,
  477. timeout: 3000,
  478. });
  479. if (visibleMenu)
  480. {
  481. try
  482. {
  483. const targetItem = await waitUntil(
  484. () =>
  485. {
  486. const items = visibleMenu.querySelectorAll(popupMenuItemsSelector);
  487. return items.length > 0 ? items : null;
  488. },
  489. {
  490. interval: 100,
  491. timeout: 5000,
  492. },
  493. );
  494.  
  495. if (targetItem)
  496. {
  497. log("🎉 Target items found:", targetItem);
  498.  
  499. for (const item of targetItem)
  500. {
  501. if (item.textContent === response)
  502. {
  503. log(`✅ Matched: (${response} = ${item.textContent})`);
  504. log(`✅`, item);
  505.  
  506. const btn = item;
  507. await retryClick(btn, { maxAttempts: 5, interval: 300 }).finally(() =>
  508. {
  509. document.body.click();
  510. });
  511. break;
  512. } else
  513. {
  514. log(`❌ Not a match: (${response} = ${item.textContent})`);
  515. }
  516. }
  517. }
  518. } catch (error)
  519. {
  520. log("🛑 !", error.message);
  521. //document.body.click()
  522. }
  523. }
  524.  
  525. //setTimeout(() => document.body.click(), 200);
  526. } catch (error)
  527. {
  528. log("🛑 !!", error.message);
  529. //document.body.click()
  530. }
  531. });