// ==UserScript==
// @name YouTube Quick Actions (Hide, Not Interested, Don’t Recommend, Save to Playlist)
// @description Adds quick-action buttons like Hide, Save to Playlist, Not Interested, and Don’t Recommend
// @version 1.7.7
// @match https://www.youtube.com/*
// @license Unlicense
// @icon https://www.youtube.com/s/desktop/c722ba88/img/logos/favicon_144x144.png
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @run-at document-start
// @compatible firefox
// @namespace https://greatest.deepsurf.us/users/1223791
// ==/UserScript==
"use strict";
console.log("🫡 [Youtube Quick Actions] Script initialized");
const css = String.raw;
const style = css`
:root {
--color-primary: rgba(252, 146, 205, 1);
--color-secondary: rgba(33, 225, 255, 1) ;
}
#quick-actions {
position: absolute;
display: none;
flex-direction: column;
gap: 0.25rem;
align-items: flex-start;
}
.location-01 {
top: 0.25rem;
left: 0.25rem;
}
.location-02 {
top: 0.4em;
left: 0.4em;
}
.qa-button {
background-color: rgba(0, 0, 0, 0.8);
/* box-shadow: inset 2px 3px 5px #000, 0px 0px 8px #d0d0d02e; */
z-index: 1000;
border: 1px solid #f0f0f01c;
width: 26px;
height: 26px;
display: flex;
justify-content: center;
align-items: center;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
flex-shrink: unset;
}
ytd-rich-item-renderer:has(.yt-spec-button-shape-next--enable-backdrop-filter-experiment:not([aria-label="Notify me"])) .qa-button.frosted,
ytd-rich-item-renderer:has(.yt-spec-button-shape-next--enable-backdrop-filter-experiment:not([aria-label="Notify me"])) .qa-button.frosted,
yt-lockup-view-model:has(.yt-spec-button-shape-next--enable-backdrop-filter-experiment:not([aria-label="Notify me"])) .qa-button.frosted {
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
box-shadow: 0px 0px 1px 0px rgba(255, 255, 255, .1);
border: 0px solid #ffffff;
}
.qa-button:hover {
border: 1px solid rgba(255, 255, 255, 0.09);
background-color: rgba(0, 0, 0, 1);
}
ytd-rich-item-renderer:has(.yt-spec-button-shape-next--enable-backdrop-filter-experiment:not([aria-label="Notify me"])) .qa-button.frosted:hover,
ytd-item-section-renderer:has(.yt-spec-button-shape-next--enable-backdrop-filter-experiment:not([aria-label="Notify me"])) .qa-button.frosted:hover,
yt-lockup-view-model:has(.yt-spec-button-shape-next--enable-backdrop-filter-experiment:not([aria-label="Notify me"])) .qa-button.frosted:hover {
opacity: 0.9;
background: rgba(40,40,40,0.6);
border: 0px solid #ffffff;
}
.qa-icon {
width: 1em;
height: 1em;
vertical-align: -0.125em;
}
#dl-button {
position: absolute;
bottom: 1rem;
left: 1rem;
}
.dl-button {
border: 1px solid rgba(240, 240, 240, 0.19);
cursor: pointer;
background-color: #0000005c;
color: #fff;
border-radius: 5px;
transition: all .4s;
padding: 2px 4px;
}
.dl-button:hover {
background-color:rgba(0, 0, 0, 0.58);
transform: translateY(-2px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
YTD-RICH-ITEM-RENDERER:has(ytd-menu-renderer):hover:not(:has(ytd-rich-grid-media[is-dismissed])):not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
YTD-RICH-GRID-MEDIA:has(ytd-menu-renderer):hover:not([is-dismissed]) #quick-actions,
YTD-RICH-ITEM-RENDERER:has(button-view-model):hover:not(:has(ytd-rich-grid-media[is-dismissed])):not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
YTD-COMPACT-VIDEO-RENDERER:has(ytd-menu-renderer):hover:not([is-dismissed]):not(:has(#dismissed-content)) #quick-actions,
YTD-COMPACT-MOVIE-RENDERER:has(ytd-menu-renderer):hover:not([is-dismissed]):not(:has(#dismissed-content)) #quick-actions,
YTM-SHORTS-LOCKUP-VIEW-MODEL-V2:hover:not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
YT-LOCKUP-VIEW-MODEL:hover:not(:has(.ytDismissibleItemReplacedContent)) #quick-actions,
YTD-PLAYLIST-VIDEO-RENDERER:has(ytd-menu-renderer):hover #quick-actions,
YTD-VIDEO-RENDERER:has(ytd-menu-renderer):hover #quick-actions,
YTD-GRID-VIDEO-RENDERER:has(ytd-menu-renderer):hover #quick-actions {
display: flex;
}
/*
#dismissible:hover:not(:has(ytm-shorts-lockup-view-model-v2)) > #quick-actions {
display: flex;
}
*/
YT-LOCKUP-VIEW-MODEL:hover:has(#quick-actions),
YTD-PLAYLIST-VIDEO-RENDERER:hover:has(#quick-actions) {
position: relative;
}
.fancy {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(
45deg,
var(--color-primary) 17%,
var(--color-secondary) 100%
);
background-size: 400% auto;
background-position: 0% 50%;
animation: animate-gradient 12s linear infinite;
font-weight: bold!important;
}
@keyframes animate-gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
`;
GM_addStyle(style);
/* -------------------------------------------------------------------------- */
/* Variables */
/* -------------------------------------------------------------------------- */
let currentUrl = "";
// Elem to search for
const featuredVideo = "YTD-RICH-GRID-MEDIA";
const normalVideoTagName = "YTD-RICH-ITEM-RENDERER";
const searchVideoTagName = "YTD-VIDEO-RENDERER";
const gridVideoTagName = "YTD-GRID-VIDEO-RENDERER";
const compactVideoTagName = "YTD-COMPACT-VIDEO-RENDERER";
const shortsV2VideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL-V2";
const shortsVideoTagName = "YTM-SHORTS-LOCKUP-VIEW-MODEL";
const compactPlaylistContainer = "YTD-ITEM-SECTION-RENDERER";
const compactMovieTagName = "YTD-COMPACT-MOVIE-RENDERER";
const compactPlaylistSelector = ".yt-lockup-view-model-wiz";
const playlistVideoTagName = "YT-LOCKUP-VIEW-MODEL";
const playlistVideoTagName2 = "YTD-PLAYLIST-VIDEO-RENDERER";
const memberVideoTagName = "YTD-MEMBERSHIP-BADGE-RENDERER";
const memberVideoSelector = ".badge-style-type-members-only";
const thumbnailElementSelector = "img.ytCoreImageHost";
const normalHamburgerMenuSelector = "button#button.style-scope.yt-icon-button";
const shortsAndPlaylistHamburgerMenuSelector = "button.yt-spec-button-shape-next";
const dropdownMenuTagName = "TP-YT-IRON-DROPDOWN";
const popupMenuItemsSelector = "yt-formatted-string.style-scope.ytd-menu-service-item-renderer, yt-list-item-view-model[role='menuitem'], yt-formatted-string.ytd-menu-navigation-item-renderer";
const communityImageSelector = "yt-img-shadow.ytd-backstage-image-renderer img";
const channelBannerImageSelector = "yt-image-banner-view-model img";
const channelProfileImageSelector = "div.yt-spec-avatar-shape__image-overlays.yt-spec-avatar-shape__image";
//Menu Extractions / Properties Path
const searchMenuPropertyPath = "menu.menuRenderer.items";
const gridMenuPropertyPath = "menu.menuRenderer.items";
const shortsMenuPropertyPath = "content.shortsLockupViewModel.menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
const shortsV2MenuPropertyPath = "menuOnTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
const normalMenuPropertyPath = "content.videoRenderer.menu.menuRenderer.items";
const playlistMenuPropertyPath = "content.lockupViewModel.metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
const playlistMenuPropertyPath2 = "menu.menuRenderer.items";
const compactPlaylistMenuPropertyPath = "metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
const compactMenuPropertyPath = "menu.menuRenderer.items";
const membersOnlyMenuPropertyPath = "content.feedEntryRenderer.item.videoRenderer.menu.menuRenderer.items";
const membersOnlyMenuPropertyPath2 = "content.videoRenderer.menu.menuRenderer.items";
const fallbackMenuPropertyPath = "metadata.lockupMetadataViewModel.menuButton.buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
const buttonFallback = "onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems";
const availableMenuItemsList1 = "listItemViewModel?.title?.content";
const availableMenuItemsList2 = "menuServiceItemRenderer?.text?.runs?.[0]?.text";
const availableMenuItemsList3 = "menuNavigationItemRenderer?.text?.runs?.[0]?.text";
const normalVideoRichThumbnailPath = "content?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails?.[0]?.url";
const normalVideoThumbnailPath = "content?.videoRenderer?.thumbnail?.thumbnails";
const compactVideoRichThumbnailPath = "richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails?.[0]?.url";
const compactVideoThumbnailPath = "thumbnail?.thumbnails";
// Icons by Remix Icon (c) Remix Design Licensed under Apache 2.0 http://www.apache.org/licenses/LICENSE-2.0 - https://github.com/Remix-Design/remixicon/blob/master/License
const notInterestedIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 17C7 14.2386 9.23858 12 12 12C14.7614 12 17 14.2386 17 17H15C15 15.3431 13.6569 14 12 14C10.3431 14 9 15.3431 9 17H7ZM8 11C7.17157 11 6.5 10.3284 6.5 9.5C6.5 8.67157 7.17157 8 8 8C8.82843 8 9.5 8.67157 9.5 9.5C9.5 10.3284 8.82843 11 8 11ZM16 11C15.1716 11 14.5 10.3284 14.5 9.5C14.5 8.67157 15.1716 8 16 8C16.8284 8 17.5 8.67157 17.5 9.5C17.5 10.3284 16.8284 11 16 11Z"></path></svg>`;
const hideIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M17.8827 19.2968C16.1814 20.3755 14.1638 21.0002 12.0003 21.0002C6.60812 21.0002 2.12215 17.1204 1.18164 12.0002C1.61832 9.62282 2.81932 7.5129 4.52047 5.93457L1.39366 2.80777L2.80788 1.39355L22.6069 21.1925L21.1927 22.6068L17.8827 19.2968ZM5.9356 7.3497C4.60673 8.56015 3.6378 10.1672 3.22278 12.0002C4.14022 16.0521 7.7646 19.0002 12.0003 19.0002C13.5997 19.0002 15.112 18.5798 16.4243 17.8384L14.396 15.8101C13.7023 16.2472 12.8808 16.5002 12.0003 16.5002C9.51498 16.5002 7.50026 14.4854 7.50026 12.0002C7.50026 11.1196 7.75317 10.2981 8.19031 9.60442L5.9356 7.3497ZM12.9139 14.328L9.67246 11.0866C9.5613 11.3696 9.50026 11.6777 9.50026 12.0002C9.50026 13.3809 10.6196 14.5002 12.0003 14.5002C12.3227 14.5002 12.6309 14.4391 12.9139 14.328ZM20.8068 16.5925L19.376 15.1617C20.0319 14.2268 20.5154 13.1586 20.7777 12.0002C19.8603 7.94818 16.2359 5.00016 12.0003 5.00016C11.1544 5.00016 10.3329 5.11773 9.55249 5.33818L7.97446 3.76015C9.22127 3.26959 10.5793 3.00016 12.0003 3.00016C17.3924 3.00016 21.8784 6.87992 22.8189 12.0002C22.5067 13.6998 21.8038 15.2628 20.8068 16.5925ZM11.7229 7.50857C11.8146 7.50299 11.9071 7.50016 12.0003 7.50016C14.4855 7.50016 16.5003 9.51488 16.5003 12.0002C16.5003 12.0933 16.4974 12.1858 16.4919 12.2775L11.7229 7.50857Z"></path></svg>`;
const saveIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 18H12V20H2V18ZM2 11H22V13H2V11ZM2 4H22V6H2V4ZM18 18V15H20V18H23V20H20V23H18V20H15V18H18Z"></path></svg>`;
const dontRecommendChannelIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM19 17.5858L21.1213 15.4645L22.5355 16.8787L20.4142 19L22.5355 21.1213L21.1213 22.5355L19 20.4142L16.8787 22.5355L15.4645 21.1213L17.5858 19L15.4645 16.8787L16.8787 15.4645L19 17.5858Z"></path></svg>`;
const downloadIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 10H18L12 16L6 10H11V3H13V10ZM4 19H20V12H22V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V12H4V19Z"></path></svg>`;
const trashIcon = `<svg class="qa-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z"></path></svg>`;
/* -------------------------------------------------------------------------- */
/* Functions */
/* -------------------------------------------------------------------------- */
/* ----------------------------- Menu Commmands ----------------------------- */
let isLoggingEnabled = GM_getValue("isLoggingEnabled", false);
let optRichThumbnail = GM_getValue("optRichThumbnail", true);
let useFrosted = GM_getValue("useFrosted", false);
const menuCommands = [
{
label: () => `Rich Thumbnail: ${optRichThumbnail ? "ON" : "OFF"}`,
toggle: function toggleRichThumbnail()
{
optRichThumbnail = !optRichThumbnail;
GM_setValue("optRichThumbnail", optRichThumbnail);
updateMenuCommands();
window.location.reload(true);
},
id: undefined,
},
{
label: () => `Logging: ${isLoggingEnabled ? "ON" : "OFF"}`,
toggle: function toggleLogging()
{
isLoggingEnabled = !isLoggingEnabled;
GM_setValue("isLoggingEnabled", isLoggingEnabled);
updateMenuCommands();
window.location.reload(true);
},
id: undefined,
},
{
label: () => `Use frosted theme: ${useFrosted ? "ON" : "OFF"}`,
toggle: function toggleFrosted()
{
useFrosted = !useFrosted;
GM_setValue("useFrosted", useFrosted);
updateMenuCommands();
window.location.reload(true);
},
id: undefined,
}
];
function registerMenuCommands()
{
for (const command of menuCommands)
{
command.id = GM_registerMenuCommand(command.label(), command.toggle);
}
}
function updateMenuCommands()
{
for (const command of menuCommands)
{
if (command.id)
{
GM_unregisterMenuCommand(command.id);
}
command.id = GM_registerMenuCommand(command.label(), command.toggle);
}
}
function toggleRichThumbnail()
{
optRichThumbnail = !optRichThumbnail;
GM_setValue("toggle5050Endorsement", optRichThumbnail);
updateMenuCommands();
window.location.reload(true);
}
function toggleLogging()
{
isLoggingEnabled = !isLoggingEnabled;
GM_setValue("isLoggingEnabled", isLoggingEnabled);
updateMenuCommands();
window.location.reload(true);
}
function toggleFrosted()
{
useFrosted = !useFrosted;
GM_setValue("useFrosted", useFrosted);
updateMenuCommands();
window.location.reload(true);
}
registerMenuCommands();
/* ---------------------------- Menu Commands End --------------------------- */
function log(...args)
{
if (isLoggingEnabled)
{
console.log(...args);
}
}
function getByPathReduce(target, path)
{
return path.split('.').reduce((result, key) => result?.[key], target) ?? [];
}
//Same result as getByPathReduce()
function getByPathFunction(object, path)
{
try
{
return new Function('object', `return object.${path}`)(object) ?? [];
} catch
{
return [];
}
}
function getDataProperty(origin, videoType)
{
const childQuerySelectors = {
"shorts-v2": shortsVideoTagName,
"compact-playlist": compactPlaylistSelector,
};
const selector = childQuerySelectors[videoType];
const target = selector ? origin.querySelector(selector) : origin;
return target?.data;
}
function getMenuList(target)
{
return target.map(item =>
{
const paths = [availableMenuItemsList1, availableMenuItemsList2, availableMenuItemsList3]; // add more as needed as they are mixed now.
for (const path of paths)
{
const result = getByPathFunction(item, path);
if (result.length) return result;
}
return null;
}).filter(Boolean);
}
function findElemInParentDomTree(origin_elem, target_selector)
{
log(`🔍 Starting search from:`, origin_elem);
let node = origin_elem;
let depth = 0;
while (node)
{
if (depth > 3)
{
log(`⛔ Depth limit reached (${depth}). Aborting search.`);
return null;
}
log(`🔍 Depth ${depth} Checking ancestor:`, node);
const found = Array.from(node.children).find(
(child) => child.matches(target_selector) || child.querySelector(target_selector)
);
if (found)
{
const result = found.matches(target_selector) ? found : found.querySelector(target_selector);
log(`✅ Found target at depth ${depth}:`, result);
return result;
}
node = node.parentElement;
depth++;
}
log(`⚠️ No matching element found after ${depth} levels.`);
return null;
}
function getVisibleElem(targetSelector)
{
const elements = document.querySelectorAll(targetSelector);
if (!elements || elements.length === 0)
{
return null;
}
for (const element of elements)
{
const rect = element.getBoundingClientRect();
const hasDimensionsAndInView = rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.left < (window.innerWidth || document.documentElement.clientWidth);
if (!hasDimensionsAndInView)
{
continue;
}
const computedStyle = window.getComputedStyle(element);
const isVisible = computedStyle.opacity !== '0' &&
computedStyle.visibility !== 'hidden' &&
computedStyle.display !== 'none';
if (!isVisible)
{
continue;
}
log("👀 Found visible element:", element);
return element;
}
log("⚠️ No visible menu found.");
return null;
}
async function waitUntil(conditionFunction, { interval = 100, timeout = 3000 } = {})
{
const startTime = Date.now();
while (Date.now() - startTime < timeout)
{
const result = conditionFunction();
if (result) return result;
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error("⏰ Timeout: Target element is not visible in time");
}
function retryClick(element, { maxAttempts = 5, interval = 300 } = {})
{
return new Promise((resolve) =>
{
let attempts = 0;
function tryClick()
{
if (!element || attempts >= maxAttempts)
{
log("⚠️ Retry failed or element missing.");
return resolve();
}
const rect = element.getBoundingClientRect();
const isVisible = rect.width > 0 && rect.height > 0;
if (isVisible)
{
element.dispatchEvent(
new MouseEvent("click", {
view: document.defaultView,
bubbles: true,
cancelable: true,
}),
);
log("👇 Clicked matching menu item");
return resolve();
} else
{
attempts++;
setTimeout(tryClick, interval);
}
}
tryClick();
});
}
function appendButtons(element, menuItems, type, position)
{
let className, titleText, icon;
let buttonsToAppend = [];
const finalMenuItems = [...new Set(menuItems)];
//If menu is empty, proceed and still append the container to prevent looping of menu data probe.
//Probe will only skip if #quick-action exist.
for (const item of finalMenuItems)
{
if (!item) continue;
let className;
let titleText;
let icon;
if (item.startsWith("Remove from "))
{
className = "remove";
titleText = "Remove from playlist";
icon = trashIcon;
} else
{
switch (item)
{
case "Not interested":
className = "not_interested";
titleText = "Not interested";
icon = notInterestedIcon;
break;
case "Don't recommend channel":
className = "dont_recommend_channel";
titleText = "Don't recommend channel";
icon = dontRecommendChannelIcon;
break;
case "Hide":
className = "hide";
titleText = "Hide video";
icon = hideIcon;
break;
case "Save to playlist":
className = "save";
titleText = "Save to playlist";
icon = saveIcon;
break;
default:
continue;
}
}
const setFrosted = useFrosted ? " frosted" : "";
buttonsToAppend.push(
`<button class="qa-button ${setFrosted} ${className}" data-icon="${className}" title="${titleText}" data-text="${titleText}">${icon}</button>`,
);
}
const buttonsContainer = document.createElement("div");
buttonsContainer.id = "quick-actions";
buttonsContainer.classList.add(position, type);
buttonsContainer.innerHTML = buttonsToAppend.join("");
//element.insertAdjacentElement("afterend", buttonsContainer);
const exist = element.querySelector("#quick-actions");
if (exist) return;
element.insertAdjacentElement("beforeend", buttonsContainer);
}
function onPageChange(callback)
{
const listenerMap = new Map();
['pushState', 'replaceState'].forEach(method =>
{
const original = history[method];
const wrapped = function (...args)
{
const result = original.apply(this, args);
window.dispatchEvent(new Event('spa-route-change'));
return result;
};
history[method] = wrapped;
listenerMap.set(method, original);
});
const onSpaRouteChange = () => callback('spa', window.location.href);
const onPopState = () => window.dispatchEvent(new Event('spa-route-change'));
const onYtAction = (event) =>
{
const actionName = event?.detail?.actionName;
if (actionName === 'yt-history-pop' || actionName === 'yt-navigate')
{
callback('yt', window.location.href);
}
};
window.addEventListener('spa-route-change', onSpaRouteChange);
window.addEventListener('popstate', onPopState);
document.addEventListener('yt-action', onYtAction);
return function cleanup()
{
for (const [method, original] of listenerMap.entries())
{
history[method] = original;
}
window.removeEventListener('spa-route-change', onSpaRouteChange);
window.removeEventListener('popstate', onPopState);
document.removeEventListener('yt-action', onYtAction);
};
}
async function downloadImage(imageUrl, filename)
{
try
{
const response = await fetch(imageUrl);
if (!response.ok)
{
log('❌ Failed to fetch image');
alert('❌ Failed to fetch image');
return;
}
const imageBlob = await response.blob();
const mimeType = imageBlob.type;
const extension = mimeType.split('/')[1].split(';')[0];
const link = document.createElement('a');
const url = URL.createObjectURL(imageBlob);
link.href = url;
link.download = `${filename}.${extension}`;
link.click();
URL.revokeObjectURL(url);
} catch (error)
{
console.error('Error downloading image:', error);
}
}
/* -------------------------------------------------------------------------- */
/* Listeners */
/* -------------------------------------------------------------------------- */
// Remove all existing quick-action elements. On certain pages, like channel tabs, content is updated in-place
// without removing the grid/container. If not cleared, old quick-action buttons will remain attached to unrelated items.
// This ensures that if the content is updated, new hover actions will fetch fresh, relevant data.
// I have not take a closer look at yt-made events. propably have some things we can customized and fire to speed things up
// skip querying and fired the action straight up via their internal events
let richThumbnailDisabler = new Date(Date.now() + 360 * 60 * 1000);
onPageChange((source, url) =>
{
richThumbnailDisabler = new Date(Date.now() + 360 * 60 * 1000);
});
document.addEventListener("yt-action", (event) =>
{
if (event.detail.actionName === "ytd-update-grid-state-action")
{
log("🐛 Page updated.");
document.querySelectorAll("#quick-actions").forEach((element) => element.remove());
document.querySelectorAll("#dl-button").forEach((element) => element.remove());
}
});
let opThumbnail, riThumbnail;
document.addEventListener("mouseover", (event) =>
{
const path = event.composedPath();
for (let element of path)
{
// Quick Actions
if (
(element.tagName === normalVideoTagName ||
element.tagName === featuredVideo ||
element.tagName === compactMovieTagName ||
element.tagName === compactVideoTagName ||
element.tagName === shortsV2VideoTagName ||
element.tagName === searchVideoTagName ||
element.tagName === gridVideoTagName ||
element.tagName === playlistVideoTagName ||
element.tagName === playlistVideoTagName2) &&
!element.querySelector("#quick-actions")
)
{
let type, data;
// Determine element type
// Hierarchy might need tweaking to simplify detection. nah this whole listener block,
// cause i'm already confused which tag is needed for which video, what need extra query, then which path
// and specific video type wont get shown unless specific step is done, even then rarely replicable to debug
// some of this type no longer valid as i go, cause i can't keep track no more
if (element.tagName === shortsV2VideoTagName)
{
type = "shorts-v2";
}
else if (element.tagName === featuredVideo )
{
type = "grid-video";
}
else if (element.tagName === playlistVideoTagName && element.parentElement.parentElement.tagName === compactPlaylistContainer)
{
type = "compact-playlist";
}
else if (element.tagName === compactMovieTagName)
{
type = "compact";
}
else if (element.tagName === gridVideoTagName)
{
type = "grid-video";
}
else if (element.tagName === searchVideoTagName)
{
type = "search-video";
}
else if (element.tagName === playlistVideoTagName && element.parentElement.parentElement.tagName === normalVideoTagName)
{
//hover listener will land on playlistVideoTagName instead of normalVideoTagName for playlist/mixes on homepage
//so manually change back to normalVideoTagName as data is there.
element = element.parentElement.parentElement;
if (element.querySelector("#quick-actions")) return;
type = "playlist";
}
else if (element.tagName === playlistVideoTagName2)
{
type = "playlist2";
}
else
{
const isShort = element.querySelector(shortsVideoTagName) !== null;
const isPlaylist = element.querySelector(playlistVideoTagName) !== null;
const isMemberOnly =
element.querySelector(memberVideoTagName) !== null ||
element.querySelector(memberVideoSelector) !== null;
type = isShort ? "shorts" :
element.tagName === compactVideoTagName ? "compact" :
isPlaylist ? "collection" :
isMemberOnly ? "members_only" :
"normal";
}
log("⭐ Video Elem: ", element.tagName, element);
log("ℹ️ Video Type: ", type);
data = getDataProperty(element, type);
const thumbnailElement = element.querySelector(thumbnailElementSelector);
const thumbnailSize =
thumbnailElement?.getClientRects?.().length > 0
? parseInt(thumbnailElement.getClientRects()[0].width)
: 100;
log("🖼️ Thumbnail Size: ", thumbnailSize);
const containerPosition = thumbnailSize < 211 ? "location-02" : "location-01";
//FIXME Temp fallback until we sure the changes are permanent
if (!data || typeof data !== "object" || Array.isArray(data))
{
log("Fallback Running");
let fallback_element;
fallback_element = element.querySelector(compactPlaylistSelector);
if (!fallback_element)
{
fallback_element = element.firstElementChild;
}
data = getDataProperty(fallback_element, "fallback");
type = "fallback";
if (!data)
{
log("Fallback2 Running");
const fallback_element2 = element.querySelector("button-view-model");
data = getDataProperty(fallback_element2, "fallback");
type = "fallback2";
}
if (!data || typeof data !== "object" || Array.isArray(data))
{
log("❌ Fallback also failed or returned invalid object.");
}
}
log("🎥 Video Props: ", data);
let menulist;
switch (type)
{
case "normal":
menulist = getByPathFunction(data, normalMenuPropertyPath);
break;
case "search-video":
menulist = getByPathFunction(data, searchMenuPropertyPath);
break;
case "grid-video":
menulist = getByPathFunction(data, gridMenuPropertyPath);
break;
case "shorts":
menulist = getByPathFunction(data, shortsMenuPropertyPath);
break;
case "shorts-v2":
menulist = getByPathFunction(data, shortsV2MenuPropertyPath);
break;
case "compact":
menulist = getByPathFunction(data, compactMenuPropertyPath);
break;
case "collection":
menulist = getByPathFunction(data, playlistMenuPropertyPath);
break;
case "playlist":
menulist = getByPathFunction(data, playlistMenuPropertyPath);
break;
case "playlist2":
menulist = getByPathFunction(data, playlistMenuPropertyPath2);
break;
case "compact-playlist":
menulist = getByPathFunction(data, compactPlaylistMenuPropertyPath);
break;
case "members_only":
menulist = getByPathFunction(data, membersOnlyMenuPropertyPath);
if (!menulist.length)
{
menulist = getByPathFunction(data, membersOnlyMenuPropertyPath2);
}
break;
//FIXME Fallback
case "fallback":
menulist = getByPathFunction(data, fallbackMenuPropertyPath);
break;
case "fallback2":
menulist = getByPathFunction(data, buttonFallback);
break;
default:
menulist = getByPathFunction(data, normalMenuPropertyPath);
break;
}
const menulistItems = getMenuList(menulist);
log("📃 Menu items: ", menulistItems);
appendButtons(element, menulistItems, type, containerPosition);
//Rich Thumbnails
//Rich thumbnail is hardcoded on dataset and expired after 6 hours therefore
//we'll disable it after 6 hours from page first loaded.
//on error check is also added to prevent gray default to be added if rich thumbnail is expired
//Disabled as we opted to use SEEKSHARE's version instead.
// if (optRichThumbnail && Date.now() < richThumbnailDisabler)
// {
// log("📸 Rich Thumbnails: ", Date.now() < richThumbnailDisabler);
// let hoverToken = null;
// const mouseOverHandler = async (event) =>
// {
// const token = Symbol();
// hoverToken = token;
// const currentThumbnail = element.querySelector("img.yt-core-image");
// const thumbailData = getDataProperty(element, type);
// const normalRichThumbnail = getByPathFunction(thumbailData, normalVideoRichThumbnailPath);
// const compactRichThumbnail = getByPathFunction(thumbailData, compactVideoRichThumbnailPath);
// const richThumbnail =
// (typeof normalRichThumbnail === 'string' && normalRichThumbnail) ||
// (typeof compactRichThumbnail === 'string' && compactRichThumbnail) ||
// undefined;
// function isImageValid(url)
// {
// return new Promise((resolve) =>
// {
// const img = new Image();
// img.onload = () => resolve(true);
// img.onerror = () => resolve(false);
// img.src = url;
// });
// }
// const isValid = await isImageValid(richThumbnail);
// if (richThumbnail && isValid && hoverToken === token)
// {
// currentThumbnail.src = richThumbnail;
// }
// };
// const mouseOutHandler = (event) =>
// {
// hoverToken = null;
// const currentThumbnail = element.querySelector("img.yt-core-image");
// const thumbnailData = getDataProperty(element, type);
// const normalThumbnails = getByPathFunction(thumbnailData, normalVideoThumbnailPath);
// const compactThumbnails = getByPathFunction(thumbnailData, compactVideoThumbnailPath);
// const biggestNormalThumbnail = normalThumbnails.at(-1)?.url;
// const biggestCompactThumbnail = compactThumbnails.at(-1)?.url;
// const staticThumbnail = biggestNormalThumbnail || biggestCompactThumbnail;
// if (staticThumbnail)
// {
// currentThumbnail.src = staticThumbnail;
// }
// };
// element.addEventListener("mouseenter", mouseOverHandler, true);
// element.addEventListener("mouseleave", mouseOutHandler, true);
// setTimeout(() =>
// {
// element.removeEventListener("mouseover", mouseOverHandler, true);
// element.removeEventListener("mouseout", mouseOutHandler, true);
// log("📸 Rich Thumbnails: disabled after timeout");
// }, richThumbnailDisabler - Date.now());
// }
}
// Quick Actions Ends
// Community Image Downloader
if (
(event.target.matches(communityImageSelector) ||
event.target.matches(channelBannerImageSelector) ||
event.target.matches(channelProfileImageSelector)) &&
(window.location.pathname.endsWith("/posts") ||
window.location.pathname.endsWith("/community") ||
window.location.pathname.includes("/community?"))
)
{
const container = event.target.parentElement;
const btnAvailable = container.querySelector("button.dl-button");
if (btnAvailable) return;
let image = event.target;
if (image.tagName !== 'IMG')
{
image = image.querySelector('img') || image.previousElementSibling;
}
if (!image || !image.src) return;
log(image);
const imageURL = image.src;
const url = new URL(imageURL);
const pathname = url.pathname;
//const regex = /^\/([A-Za-z0-9_-]+(?:=[A-Za-z0-9_-]+)*)(?==s)/;
const regex = /^\/((?:ytc\/)?[A-Za-z0-9_-]+)/;
//const newPathname = pathname.replace(regex, "/$1");
const match = pathname.match(regex);
const buttonsContainer = document.createElement("div");
buttonsContainer.id = "dl-button";
buttonsContainer.innerHTML = `<button class="qa-button dl-button" title="Download Image" data-text="Download Image">${downloadIcon}</button>`;
const downloadButton = buttonsContainer.querySelector("button.dl-button");
if (match)
{
const downloadURL = url.origin + "/" + match[1] + "=s0";
log("🔗 Download URL:", downloadURL);
downloadButton.addEventListener("click", (event) =>
{
event.stopPropagation();
event.preventDefault();
event.stopImmediatePropagation();
downloadImage(downloadURL, match[1]);
});
image.parentElement.appendChild(buttonsContainer);
}
}
// Community Image Downloader Ends
}
}, true);
document.addEventListener("click", async function (event)
{
const button = event.target.closest(".qa-button");
if (!button) return;
event.stopPropagation();
event.stopImmediatePropagation();
event.preventDefault();
const actionType = button.dataset.icon;
let response;
switch (actionType)
{
case "not_interested":
response = "Not interested";
log("😴 Marking as not interested");
break;
case "dont_recommend_channel":
response = "Don't recommend channel";
log("🚫 Don't recommend channel");
break;
case "hide":
response = "Hide";
log("🗑️ Hiding video");
break;
case "remove":
response = "Remove from";
log("🗑️ Remove from playlist");
break;
case "save":
response = "Save to playlist";
log("📂 Saving to playlist");
break;
default:
log("☠️ Unknown action");
}
let menupath;
if (button.parentElement.parentElement.tagName === shortsV2VideoTagName || button.parentElement.parentElement.querySelector(playlistVideoTagName))
{
menupath = shortsAndPlaylistHamburgerMenuSelector;
}
else if (button.parentElement.classList.contains("shorts"))
{
//shorts but not inside shortsv2 container idk where i found this its gone now crazy i was crazy once
//been a while, probably safe to remove now.
menupath = shortsAndPlaylistHamburgerMenuSelector;
}
else if (button.parentElement.classList.contains("compact-playlist"))
{
menupath = shortsAndPlaylistHamburgerMenuSelector;
}
//FIXME FALLBACK
else if (button.parentElement.parentElement.classList.contains("ytd-watch-next-secondary-results-renderer") || button.parentElement.classList.contains("fallback"))
{
menupath = shortsAndPlaylistHamburgerMenuSelector;
}
else
{
menupath = normalHamburgerMenuSelector;
}
const menus = findElemInParentDomTree(button, menupath);
if (!menus)
{
log("❌ Menu button not found.");
return;
}
menus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
log("👇 Button clicked, waiting for menu...");
try
{
const visibleMenu = await waitUntil(() => getVisibleElem(dropdownMenuTagName), {
interval: 100,
timeout: 3000,
});
if (visibleMenu)
{
try
{
const targetItem = await waitUntil(
() =>
{
const items = visibleMenu.querySelectorAll(popupMenuItemsSelector);
return items.length > 0 ? items : null;
},
{
interval: 100,
timeout: 5000,
},
);
if (targetItem)
{
log("🎉 Target items found:", targetItem);
for (const item of targetItem)
{
if (
item.textContent === response ||
(response === "Remove from" && item.textContent.startsWith("Remove from"))
)
{
log(`✅ Matched: (${response} = ${item.textContent})`);
log(`✅`, item);
const button = item;
await retryClick(button, { maxAttempts: 5, interval: 300 }).finally(() =>
{
document.body.click();
});
break;
} else
{
log(`❌ Not a match: (${response} = ${item.textContent})`);
}
}
}
} catch (error)
{
log("🛑 !", error.message);
//document.body.click()
}
}
//setTimeout(() => document.body.click(), 200);
} catch (error)
{
log("🛑 !!", error.message);
//document.body.click()
}
});
/* -------------------------------------------------------------------------- */
/* Rich Thumbnail */
/* -------------------------------------------------------------------------- */
// Based on userscript by seekhare - https://greatest.deepsurf.us/en/scripts/483703
// Version 3.0 @ 14 June 2025 - MIT Licensed.
/*
* MIT License
*
* Copyright (c) 2024 seekhare
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
if (optRichThumbnail)
{
let lastTarget = null;
const descriptorTrap = new Proxy(Object.getOwnPropertyDescriptor, {
apply(target, thisArg, args)
{
const [obj, key] = args;
if (
key === 'animatedThumbnailEnabled' ||
key === 'inlinePreviewEnabled' ||
key === 'isPreviewDisabled'
)
{
if (!obj.__patchedPreviewProps)
{
lastTarget = obj;
try
{
Object.defineProperties(obj, {
animatedThumbnailEnabled: {
get()
{
return true;
},
set() { }
},
inlinePreviewEnabled: {
get()
{
return false;
},
set() { }
},
isPreviewDisabled: {
get()
{
return false;
},
set() { }
}
});
obj.__patchedPreviewProps = true;
//log('Patch applied to object:', obj);
} catch (e)
{
log('Failed to patch object:', e);
}
}
}
return Reflect.apply(target, thisArg, args);
}
});
Object.getOwnPropertyDescriptor = descriptorTrap;
}
/* -------------------------------------------------------------------------- */
/* This script is brought to you in support of FIFTY FIFTY 💖 */
/* -------------------------------------------------------------------------- */
if ("💖")
{
const selectorsToWatch = ['a', 'yt-formatted-string'];
const observedElements = new WeakMap();
function observeTextContentChanges(element)
{
if (observedElements.has(element)) return;
const elementObserver = new MutationObserver(() =>
{
const hasText = element.textContent.trim() === "FIFTY FIFTY Official";
element.classList.toggle("fancy", hasText);
});
observedElements.set(element, elementObserver);
elementObserver.observe(element, { characterData: true, childList: true, subtree: true });
}
function init()
{
document.querySelectorAll(selectorsToWatch.join(',')).forEach(element =>
{
if (element.textContent.trim() === "FIFTY FIFTY Official") element.classList.add("fancy");
observeTextContentChanges(element);
});
const observer = new MutationObserver(mutations =>
{
for (const mutation of mutations)
{
for (const node of mutation.addedNodes)
{
if (node.nodeType !== 1) continue;
for (const selector of selectorsToWatch)
{
const elements = node.matches(selector) ? [node] : node.querySelectorAll(selector);
elements.forEach(element =>
{
if (element.textContent.trim() === "FIFTY FIFTY Official") element.classList.add("fancy");
observeTextContentChanges(element);
});
}
}
for (const node of mutation.removedNodes)
{
if (node.nodeType === 1 && observedElements.has(node))
{
observedElements.get(node).disconnect();
observedElements.delete(node);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// Delay until body exists
if (document.body)
{
init();
} else
{
new MutationObserver((_, obs) =>
{
if (document.body)
{
obs.disconnect();
init();
}
}).observe(document.documentElement, { childList: true });
}
}