Created
February 26, 2025 19:26
-
-
Save azdanov/42661b54de30cb31448ca64d59361ee7 to your computer and use it in GitHub Desktop.
Hover a thumbnail on YouTube to quickly mark "Not interested" or "Don't recommend channel"
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name YT: not interested in one click | |
| // @description Hover a thumbnail on YouTube to quickly mark "Not interested" or "Don't recommend channel" | |
| // @version 1.4.0 | |
| // | |
| // @match https://www.youtube.com/* | |
| // | |
| // @noframes | |
| // @grant none | |
| // | |
| // @author wOxxOm | |
| // @namespace wOxxOm.scripts | |
| // @license MIT License | |
| // @downloadURL https://update.greasyfork.org/scripts/396936/YT%3A%20not%20interested%20in%20one%20click.user.js | |
| // @updateURL https://update.greasyfork.org/scripts/396936/YT%3A%20not%20interested%20in%20one%20click.meta.js | |
| // ==/UserScript== | |
| 'use strict'; | |
| /** | |
| * YouTube One-Click Dismiss | |
| * Adds buttons to quickly dismiss videos or channels on YouTube | |
| */ | |
| class YouTubeOneClickDismiss { | |
| constructor() { | |
| // Constants | |
| this.THUMB2 = 'yt-thumbnail-view-model'; | |
| this.ME = 'yt-one-click-dismiss'; | |
| this.COMMANDS = { | |
| NOT_INTERESTED: 'video', | |
| REMOVE: 'channel', | |
| DELETE: 'unwatch', | |
| }; | |
| // Load config from storage or use defaults | |
| const config = this.getStorage() || {}; | |
| this.THUMB = config.THUMB || `${this.THUMB2},ytd-thumbnail,ytd-playlist-thumbnail`; | |
| this.PREVIEW_TAG = config.PREVIEW_TAG || 'ytd-video-preview'; | |
| this.PREVIEW_PARENT = config.PREVIEW_PARENT || '#media-container'; | |
| this.ENTRY = config.ENTRY || [ | |
| 'ytd-rich-item-renderer', // home | |
| 'ytd-compact-video-renderer', // watch (recommendations) | |
| 'ytd-playlist-video-renderer', // watch later, likes | |
| 'ytd-playlist-panel-video-renderer', // playlist | |
| 'ytd-video-renderer', // history | |
| ].join(','); | |
| this.MENU1 = config.MENU1 || 'ytd-menu-popup-renderer'; | |
| this.MENU2 = config.MENU2 || 'yt-sheet-view-model'; | |
| this.MENU_BTN = config.MENU_BTN || '.dropdown-trigger, .yt-lockup-metadata-view-model-wiz__menu-button button'; | |
| this.STYLE = null; | |
| this.inlinable = null; | |
| // Initialize | |
| this.init(); | |
| } | |
| init() { | |
| // Add event listeners | |
| addEventListener('click', this.onClick.bind(this), true); | |
| addEventListener('mousedown', this.onClick.bind(this), true); | |
| addEventListener('mouseover', this.onHover.bind(this), true); | |
| addEventListener('yt-action', this.onYtAction.bind(this)); | |
| } | |
| onYtAction({detail: d}) { | |
| if (d.actionName === 'yt-set-cookie-command') { | |
| this.inlinable = !d.args[0].setCookieCommand.value; | |
| } | |
| } | |
| onHover(evt, delayed) { | |
| const inline = evt.target.closest(this.PREVIEW_TAG); | |
| const el = inline || evt.target.closest(this.THUMB); | |
| const thumb = el && inline === el ? this.$(this.THUMB, inline) : el; | |
| if (!thumb || thumb.getElementsByClassName(this.ME)[0]) { | |
| return; | |
| } | |
| const shouldAddButtons = inline || delayed || this.shouldAddButtonsForThumbnail(evt); | |
| if (shouldAddButtons) { | |
| if (inline) { | |
| this.addButtons( | |
| this.$(this.PREVIEW_PARENT, el), | |
| this.getProp(el, 'mediaRenderer') || this.getProp(el, 'opts.mediaRenderer') | |
| ); | |
| } else { | |
| this.addButtons(thumb, thumb); | |
| } | |
| } | |
| } | |
| shouldAddButtonsForThumbnail(evt) { | |
| if (this.inlinable != null) { | |
| return !this.inlinable; | |
| } | |
| const previewEl = this.$(this.PREVIEW_TAG); | |
| this.inlinable = this.getProp(previewEl, 'inlinePreviewIsEnabled'); | |
| if (this.inlinable != null) { | |
| return !this.inlinable; | |
| } | |
| setTimeout(() => this.getInlineState(evt), 250); | |
| return false; | |
| } | |
| async onClick(e) { | |
| if (e.button) return; | |
| const me = e.target; | |
| const thumb = me[this.ME]; | |
| if (!thumb) return; | |
| const a = me.closest('a'); | |
| const upd = thumb.localName === this.THUMB2; | |
| const MENU = upd ? this.MENU2 : this.MENU1; | |
| const POPUPICON = upd | |
| ? 'props.data.leadingImage.sources.0.clientResource.imageName' | |
| : 'data.icon.iconType'; | |
| e.stopPropagation(); | |
| e.stopImmediatePropagation(); | |
| e.preventDefault(); | |
| if (e.type === 'click') return; | |
| if (a) this.setPointerEvents(a, 'none'); | |
| await new Promise(r => me.addEventListener('mouseup', r, {once: true})); | |
| let index, menu, popup, entry, el; | |
| if ((entry = thumb.closest(this.ENTRY)) && (el = this.$(this.MENU_BTN, entry))) { | |
| await Promise.resolve(); // Microtask delay | |
| index = this.STYLE.sheet.insertRule(`${MENU}:not(#\\0) { opacity: 0 !important }`); | |
| el.dispatchEvent(new Event('click')); | |
| if ((popup = await this.waitFor('ytd-popup-container'))) { | |
| menu = await this.waitFor(MENU, popup); | |
| } | |
| } | |
| if (a) setTimeout(() => this.setPointerEvents(a), 0); | |
| if (!menu) { | |
| this.STYLE.sheet.deleteRule(index); | |
| this.handleMenuNotFound(me); | |
| return; | |
| } | |
| if (me.title) me.title = ''; | |
| await this.waitForMenuReady(menu); | |
| await new Promise(setTimeout); | |
| el = this.getProp(popup, `popups_.${MENU}.target`, true); | |
| if (a) a.style.removeProperty('pointer-events'); | |
| if (el && !entry.contains(el)) { | |
| console.warn('Menu is not for the video you clicked', [menu, entry]); | |
| this.STYLE.sheet.deleteRule(index); | |
| return; | |
| } | |
| try { | |
| this.clickMenuOption(menu, me.dataset.block, upd, POPUPICON); | |
| } catch (e) { | |
| console.error('Error clicking menu option:', e); | |
| } | |
| await new Promise(setTimeout); | |
| document.body.click(); | |
| await new Promise(setTimeout); | |
| this.STYLE.sheet.deleteRule(index); | |
| } | |
| async handleMenuNotFound(me) { | |
| const el = me.nextSibling; | |
| me.remove(); | |
| me.title = 'No menu button?\nWait a few seconds for the site to load.'; | |
| await new Promise(setTimeout); | |
| el.before(me); | |
| await this.timedPromise(null, 5000); | |
| me.title = ''; | |
| } | |
| async waitForMenuReady(menu) { | |
| if (!this.isMenuReady(menu)) { | |
| let mo; | |
| const success = await this.timedPromise(resolve => { | |
| mo = new MutationObserver(() => this.isMenuReady(menu) && resolve(true)); | |
| mo.observe(menu, {attributes: true, attributeFilter: ['style']}); | |
| }); | |
| if (!success) console.warn('Timeout waiting for px in `style` of', menu); | |
| mo.disconnect(); | |
| } | |
| } | |
| clickMenuOption(menu, blockType, upd, POPUPICON) { | |
| for (const el of this.$('[role=listbox], [role=menu]', menu).children) { | |
| const iconType = this.getProp(el, POPUPICON); | |
| const command = this.COMMANDS[iconType]; | |
| if (blockType === (command || {}).block) { | |
| el.click(); | |
| break; | |
| } | |
| } | |
| } | |
| addButtons(parent, thumb) { | |
| const upd = thumb.localName === this.THUMB2; | |
| const ITEMS = upd | |
| ? 'data.content.lockupViewModel.metadata.lockupMetadataViewModel.menuButton.' + | |
| 'buttonViewModel.onTap.innertubeCommand.showSheetCommand.panelLoadingStrategy.' + | |
| 'inlineContent.sheetViewModel.content.listViewModel.listItems' | |
| : 'data.menu.menuRenderer.items'; | |
| const ITEM = upd ? 'listItemViewModel' : 'menuServiceItemRenderer'; | |
| const ICON = upd ? 'leadingImage.sources.0.clientResource.imageName' : 'icon.iconType'; | |
| const TEXT = upd ? 'title.content' : 'text.runs.0.text'; | |
| const elems = []; | |
| const shown = {}; | |
| const items = this.getProp(upd ? thumb.closest(this.ENTRY) : thumb, ITEMS) || []; | |
| for (const item of items) { | |
| const menu = item[ITEM]; | |
| const type = this.getProp(menu, ICON); | |
| let data = this.COMMANDS[type]; | |
| if (!data) continue; | |
| let {el} = data; | |
| if (!el) { | |
| data = this.COMMANDS[type] = {block: data}; | |
| el = data.el = document.createElement('div'); | |
| el.className = this.ME; | |
| el.dataset.block = data.block; | |
| } | |
| el.title = this.getProp(menu, TEXT) || data.text; | |
| el[this.ME] = thumb; | |
| shown[type] = 1; | |
| if (el.parentElement !== parent) { | |
| elems.push(el); | |
| } | |
| } | |
| // Remove buttons that shouldn't be shown | |
| for (let v in this.COMMANDS) { | |
| if (!shown[v] && (v = this.COMMANDS[v].el)) { | |
| v.remove(); | |
| } | |
| } | |
| if (elems.length) { | |
| requestAnimationFrame(() => parent.append(...elems)); | |
| } | |
| if (!this.STYLE) this.initStyle(); | |
| } | |
| getInlineState(e) { | |
| if (e.target.matches(':hover') && !this.$(this.PREVIEW_TAG).getBoundingClientRect().width) { | |
| this.onHover(e, true); | |
| } | |
| } | |
| getProp(obj, path, isRaw) { | |
| if (!obj) return; | |
| if (obj instanceof Node) { | |
| obj = (obj = obj.wrappedJSObject || obj).polymerController || obj.__instance || obj.inst || obj; | |
| obj = !isRaw && obj.__data || obj; | |
| } | |
| for (const p of path.split('.')) { | |
| if (obj) obj = obj[p]; | |
| else return; | |
| } | |
| return obj; | |
| } | |
| getStorage() { | |
| try { | |
| return JSON.parse(localStorage[GM_info.script.name]); | |
| } catch (e) { | |
| return null; | |
| } | |
| } | |
| isMenuReady(menu) { | |
| return menu.style.cssText.includes('px;'); | |
| } | |
| $(sel, base = document) { | |
| return base.querySelector(sel); | |
| } | |
| setPointerEvents(el, value = null) { | |
| if (value != null) { | |
| el.style.setProperty('pointer-events', value, 'important'); | |
| } else { | |
| el.style.removeProperty('pointer-events'); | |
| } | |
| } | |
| timedPromise(promiseInit, ms = 1000) { | |
| const timeoutPromise = new Promise(resolve => setTimeout(resolve, ms)); | |
| return promiseInit | |
| ? Promise.race([timeoutPromise, new Promise(promiseInit)]) | |
| : timeoutPromise; | |
| } | |
| async waitFor(sel, base = document) { | |
| const existingElement = this.$(sel, base); | |
| if (existingElement) return existingElement; | |
| return this.timedPromise(resolve => { | |
| const observer = new MutationObserver((_, o) => { | |
| const el = this.$(sel, base); | |
| if (!el) return; | |
| o.disconnect(); | |
| resolve(el); | |
| }); | |
| observer.observe(base, {childList: true, subtree: true}); | |
| }); | |
| } | |
| initStyle() { | |
| this.STYLE = document.createElement('style'); | |
| this.STYLE.textContent = /*language=CSS*/ ` | |
| ${this.PREVIEW_PARENT} .${this.ME} { | |
| opacity: .5; | |
| } | |
| ${this.PREVIEW_PARENT} .${this.ME}, | |
| :is(${this.THUMB}):hover .${this.ME}, | |
| :is(${this.THUMB}):hover ~ .${this.ME} { | |
| display: block; | |
| } | |
| .${this.ME} { | |
| display: none; | |
| position: absolute; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 100%; | |
| border: 2px solid #fff; | |
| right: 8px; | |
| margin: 0; | |
| padding: 0; | |
| background: #0006; | |
| box-shadow: .5px .5px 7px #000; | |
| pointer-events: auto; | |
| cursor: pointer; | |
| opacity: .75; | |
| z-index: 11000; | |
| } | |
| ${this.PREVIEW_PARENT} .${this.ME}:hover, | |
| .${this.ME}:hover { | |
| opacity: 1; | |
| } | |
| .${this.ME}:active { | |
| color: yellow; | |
| } | |
| .${this.ME}[data-block] { top: 75px; } | |
| .${this.ME}[data-block="channel"] { top: 105px; } | |
| yt-thumbnail-view-model .${this.ME} { margin-top: 10px; } | |
| ${this.PREVIEW_TAG} .${this.ME}[data-block] { right: 18px; margin-top: 24px; } | |
| .ytd-playlist-panel-video-renderer .${this.ME}[data-block="unwatch"], | |
| .ytd-playlist-video-renderer .${this.ME}[data-block="unwatch"] { | |
| top: 15px; | |
| } | |
| ytd-compact-video-renderer .${this.ME}[data-block] { | |
| top: 57px; | |
| right: 7px; | |
| box-shadow: .5px .5px 4px 6px #000; | |
| background: #000; | |
| } | |
| ytd-compact-video-renderer .${this.ME}[data-block="channel"] { | |
| top: 81px; | |
| } | |
| ytd-compact-video-renderer ytd-thumbnail-overlay-toggle-button-renderer:nth-child(1) { | |
| top: -4px; | |
| } | |
| ytd-compact-video-renderer ytd-thumbnail-overlay-toggle-button-renderer:nth-child(2) { | |
| top: 24px; | |
| } | |
| .${this.ME}::before { | |
| position: absolute; | |
| content: ''; | |
| top: -8px; | |
| left: -6px; | |
| width: 32px; | |
| height: 30px; | |
| } | |
| .${this.ME}::after { | |
| content: ""; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| height: 0; | |
| margin: auto; | |
| border: none; | |
| border-bottom: 2px solid #fff; | |
| } | |
| .${this.ME}[data-block="video"]::after { | |
| transform: rotate(45deg); | |
| } | |
| .${this.ME}[data-block="channel"]::after { | |
| margin: auto 3px; | |
| } | |
| `.replace(/;/g, '!important;'); | |
| document.head.appendChild(this.STYLE); | |
| } | |
| } | |
| // Initialize the script | |
| new YouTubeOneClickDismiss(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment