// ==UserScript== // @name YouTube: Hide Watched Videos // @namespace https://www.haus.gg/ // @version 6.22 // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @description Hides watched videos, Shorts, Mixes, and subscribed channels from your YouTube feeds. // @author Ev Haus // @author netjeff // @author actionless // @author ceeprus // @match http://*.youtube.com/* // @match http://youtube.com/* // @match https://*.youtube.com/* // @match https://youtube.com/* // @noframes // @require https://openuserjs.org/src/libs/sizzle/GM_config.js // @grant GM_getValue // @grant GM_setValue // @grant GM.getValue // @grant GM.setValue // @downloadURL https://raw.githubusercontent.com/ceeprus/youtube-hide-watched/master/main.user.js // @updateURL https://raw.githubusercontent.com/ceeprus/youtube-hide-watched/master/main.user.js // ==/UserScript== // To submit bugs or submit revisions please see visit the repository at: // https://github.com/ceeprus/youtube-hide-watched // You can open new issues at: // https://github.com/ceeprus/youtube-hide-watched/issues const REGEX_CHANNEL = /.*\/(user|channel|c)\/.+\/videos/u; const REGEX_USER = /.*\/@.*/u; ((_undefined) => { // Enable for debugging const DEBUG = false; // Needed to bypass YouTube's Trusted Types restrictions, ie. // Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment. if ( typeof trustedTypes !== 'undefined' && trustedTypes.defaultPolicy === null ) { const s = (s) => s; trustedTypes.createPolicy('default', { createHTML: s, createScript: s, createScriptURL: s, }); } // GM_config setup const title = document.createElement('a'); title.textContent = 'YouTube: Hide Watched Videos Settings'; title.href = 'https://github.com/ceeprus/youtube-hide-watched'; title.target = '_blank'; const gmc = new GM_config({ events: { async save() { this.close(); // Re-apply the tint strength immediately applySubbedTintSetting(); // Honor the "refresh now" checkbox, then reset it if (this.get('SUBBED_FORCE_REFRESH')) { this.set('SUBBED_FORCE_REFRESH', false); this.write(); await loadSubs(true); } await updateClassOnSubscribedItems(); }, }, fields: { HIDDEN_THRESHOLD_PERCENT: { default: 10, label: 'Hide/Dim Videos Above Percent', max: 100, min: 0, type: 'int', }, SUBBED_DIM_BRIGHTNESS: { default: 45, label: 'Subscribed Channels Tint Brightness % (lower = darker)', max: 100, min: 0, type: 'int', }, SUBBED_REFRESH_HOURS: { default: 24, label: 'Refresh Subscription List Every (hours)', max: 720, min: 1, type: 'int', }, SUBBED_FORCE_REFRESH: { default: false, label: 'Refresh subscription list now (on Save)', type: 'checkbox', }, }, id: 'YouTubeHideWatchedVideos', title, }); // Set defaults localStorage.YTHWV_WATCHED = localStorage.YTHWV_WATCHED || 'false'; const logDebug = (...msgs) => { if (DEBUG) console.debug('[YT-HWV]', msgs); }; // Storage helpers — prefer GM storage (persists across YouTube's // localStorage clears) but fall back to localStorage if GM APIs // are unavailable. const stateGet = async (key, defaultValue) => { try { return GM_getValue(key, defaultValue); } catch (_) { /* fall through */ } try { return await GM.getValue(key, defaultValue); } catch (_) { /* fall through */ } return localStorage.getItem(key) ?? defaultValue; }; const stateSet = async (key, value) => { try { GM_setValue(key, value); return; } catch (_) { /* fall through */ } try { await GM.setValue(key, value); return; } catch (_) { /* fall through */ } localStorage.setItem(key, value); }; // GreaseMonkey no longer supports GM_addStyle. So we have to define // our own polyfill here const addStyle = (aCss) => { const head = document.getElementsByTagName('head')[0]; if (head) { const style = document.createElement('style'); style.setAttribute('type', 'text/css'); style.textContent = aCss; head.appendChild(style); return style; } return null; }; addStyle(` .YT-HWV-WATCHED-HIDDEN { display: none !important } .YT-HWV-WATCHED-DIMMED { opacity: 0.3 } .YT-HWV-SHORTS-HIDDEN { display: none !important } .YT-HWV-SHORTS-DIMMED { opacity: 0.3 } .YT-HWV-MIXES-HIDDEN { display: none !important } .YT-HWV-MIXES-DIMMED { opacity: 0.3 } .YT-HWV-SUBBED-HIDDEN { display: none !important } .YT-HWV-SUBBED-DIMMED { filter: brightness(var(--ythwv-subbed-brightness, 0.45)) saturate(0.7) !important; transition: filter 0.15s ease; } .YT-HWV-HIDDEN-ROW-PARENT { padding-bottom: 10px } .YT-HWV-BUTTONS { background: transparent; border: 1px solid var(--ytd-searchbox-legacy-border-color); border-radius: 40px; display: flex; gap: 5px; margin: 0 20px; } .YT-HWV-BUTTON { align-items: center; background: transparent; border: 0; border-radius: 40px; color: #0F0F0F; cursor: pointer; display: flex; height: 40px; justify-content: center; outline: 0; width: 40px; } [dark] .YT-HWV-BUTTON { color: #F1F1F1; } .YT-HWV-BUTTON:focus, .YT-HWV-BUTTON:hover { background: var(--yt-spec-additive-background); } .YT-HWV-BUTTON-DISABLED { opacity: 0.5 } .YT-HWV-MENU { background: #F8F8F8; border: 1px solid #D3D3D3; box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); display: none; font-size: 12px; margin-top: -1px; padding: 10px; position: absolute; right: 0; text-align: center; top: 100%; white-space: normal; z-index: 9999; } .YT-HWV-MENU-ON { display: block; } .YT-HWV-MENUBUTTON-ON span { transform: rotate(180deg) } `); const BUTTONS = [ { icon: '', iconHidden: '', name: 'Toggle Watched Videos', stateKey: 'YTHWV_STATE', type: 'toggle', }, { icon: '', iconHidden: '', name: 'Toggle Shorts', stateKey: 'YTHWV_STATE_SHORTS', type: 'toggle', }, { icon: '', iconHidden: '', name: 'Toggle Mixes', stateKey: 'YTHWV_STATE_MIXES', type: 'toggle', }, { icon: '', iconHidden: '', name: 'Toggle Subscribed Channels (homepage)', stateKey: 'YTHWV_STATE_SUBBED', type: 'toggle', global: true, }, { icon: '', name: 'Settings', type: 'settings', }, ]; // =========================================================== const debounce = function (func, wait, immediate) { let timeout; return (...args) => { const later = () => { timeout = null; if (!immediate) func.apply(this, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(this, args); }; }; // =========================================================== const findWatchedElements = () => { const watched = document.querySelectorAll( [ '.ytd-thumbnail-overlay-resume-playback-renderer', // Recommended videos on the right-hand sidebar when watching a video '.ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment', // 2025-02-01 Update '.ytThumbnailOverlayProgressBarHostWatchedProgressBarSegmentModern', ].join(','), ); const withThreshold = Array.from(watched).filter((bar) => { return ( bar.style.width && Number.parseInt(bar.style.width, 10) >= gmc.get('HIDDEN_THRESHOLD_PERCENT') ); }); // YouTube Watchmarker (sniklaus/youtube-watchmarker) tags the thumbnail // link of any video in its history with the `youwatch-mark` class, // covering videos YouTube itself no longer draws a progress bar for. // Those count as fully watched, so they bypass the progress threshold. const marked = Array.from(document.querySelectorAll('a.youwatch-mark')); logDebug( `Found ${watched.length} progress bars ` + `(${withThreshold.length} within threshold) + ` + `${marked.length} watchmarker marks`, ); return [...withThreshold, ...marked]; }; // =========================================================== const findShortsContainers = () => { const shortsContainers = [ // All pages (2024-09 update) document.querySelectorAll('[is-shorts]'), // Subscriptions Page (List View) document.querySelectorAll( 'ytd-reel-shelf-renderer ytd-reel-item-renderer', ), document.querySelectorAll( 'ytd-rich-shelf-renderer ytd-rich-grid-slim-media', ), // Home Page & Subscriptions Page (Grid View) document.querySelectorAll('ytd-reel-shelf-renderer ytd-thumbnail'), // Search results page document.querySelectorAll( 'ytd-reel-shelf-renderer .ytd-reel-shelf-renderer', ), // Search results apge (2025-06 update) document.querySelectorAll('ytm-shorts-lockup-view-model-v2'), ].reduce((acc, matches) => { matches?.forEach((child) => { const container = child.closest('ytd-reel-shelf-renderer') || child.closest('ytd-rich-shelf-renderer') || child.closest('grid-shelf-view-model'); if (container && !acc.includes(container)) acc.push(container); }); return acc; }, []); // Search results sometimes also show Shorts as if they're regular videos with a little "Shorts" badge document .querySelectorAll( '.ytd-thumbnail-overlay-time-status-renderer[aria-label="Shorts"]', ) .forEach((child) => { const container = child.closest('ytd-video-renderer'); if (container) shortsContainers.push(container); }); logDebug(`Found ${shortsContainers.length} shorts container elements`); return shortsContainers; }; // =========================================================== const findMixesContainers = () => { const mixesContainers = []; document .querySelectorAll( 'a[href*="start_radio=1"], a[href*="list=RD"], a[href*="&list=RD"]', ) .forEach((link) => { const container = // Home / Subscriptions grid cell link.closest('ytd-rich-item-renderer') || link.closest('ytd-grid-video-renderer') || // Search results (legacy + lockup) link.closest('ytd-radio-renderer') || link.closest('ytd-video-renderer') || // Watch page right-hand sidebar link.closest('ytd-compact-radio-renderer') || link.closest('ytd-compact-video-renderer') || // New unified lockup (search + sidebar, 2024+) link.closest('yt-lockup-view-model'); if (!container) return; // Never hide the item that's queued to play next. if (container.closest('ytd-compact-autoplay-renderer')) return; if (!mixesContainers.includes(container)) { mixesContainers.push(container); } }); logDebug(`Found ${mixesContainers.length} mixes container elements`); return mixesContainers; }; // =========================================================== const findButtonAreaTarget = () => { // Button will be injected into the main header menu return document.querySelector('#container #end #buttons'); }; // =========================================================== const determineYoutubeSection = () => { const { href } = window.location; let youtubeSection = 'misc'; if (href.includes('/watch?')) { youtubeSection = 'watch'; } else if (href.match(REGEX_CHANNEL) || href.match(REGEX_USER)) { youtubeSection = 'channel'; } else if (href.includes('/feed/subscriptions')) { youtubeSection = 'subscriptions'; } else if (href.includes('/feed/trending')) { youtubeSection = 'trending'; } else if (href.includes('/playlist?')) { youtubeSection = 'playlist'; } else if (href.includes('/results?')) { youtubeSection = 'search'; } else if (href.includes('/hashtag/')) { youtubeSection = 'hashtag'; } return youtubeSection; }; // =========================================================== const updateClassOnWatchedItems = async () => { try { // Remove existing classes document.querySelectorAll('.YT-HWV-WATCHED-DIMMED').forEach((el) => { el.classList.remove('YT-HWV-WATCHED-DIMMED'); }); document.querySelectorAll('.YT-HWV-WATCHED-HIDDEN').forEach((el) => { el.classList.remove('YT-HWV-WATCHED-HIDDEN'); }); // If we're on the History page -- do nothing. We don't want to hide // watched videos here. if (window.location.href.indexOf('/feed/history') >= 0) return; const section = determineYoutubeSection(); const state = await stateGet(`YTHWV_STATE_${section}`); findWatchedElements().forEach((item, _i) => { let watchedItem; let dimmedItem; // "Subscription" section needs us to hide the "#contents", // but in the "Trending" section, that class will hide everything. // So there, we need to hide the "ytd-video-renderer" if (section === 'subscriptions') { // For rows, hide the row and the header too. We can't hide // their entire parent because then we'll get the infinite // page loader to load forever. watchedItem = // Grid item item.closest('.ytd-grid-renderer') || item.closest('.ytd-item-section-renderer') || item.closest('.ytd-rich-grid-row') || item.closest('.ytd-rich-grid-renderer') || // List item item.closest('#grid-container'); // If we're hiding the .ytd-item-section-renderer element, we need to give it // some extra spacing otherwise we'll get stuck in infinite page loading if (watchedItem?.classList.contains('ytd-item-section-renderer')) { watchedItem .closest('ytd-item-section-renderer') .classList.add('YT-HWV-HIDDEN-ROW-PARENT'); } } else if (section === 'playlist') { watchedItem = item.closest('ytd-playlist-video-renderer'); } else if (section === 'watch') { watchedItem = item.closest('ytd-compact-video-renderer') || // Recommended videos on the right-hand sidebar when watching a video (#370) item.closest('yt-lockup-view-model'); // Don't hide video if it's going to play next. // // If there is no watchedItem - we probably got // `ytd-playlist-panel-video-renderer`: // let's also ignore it as in case of shuffle enabled // we could accidentially hide the item which gonna play next. if (watchedItem?.closest('ytd-compact-autoplay-renderer')) { watchedItem = null; } // For playlist items, we never hide them, but we will dim // them even if current mode is to hide rather than dim. const watchedItemInPlaylist = item.closest( 'ytd-playlist-panel-video-renderer', ); if (!watchedItem && watchedItemInPlaylist) { dimmedItem = watchedItemInPlaylist; } } else { // For home page and other areas watchedItem = item.closest('ytd-rich-item-renderer') || item.closest('ytd-video-renderer') || item.closest('ytd-grid-video-renderer'); } if (watchedItem) { // Add current class if (state === 'dimmed') { watchedItem.classList.add('YT-HWV-WATCHED-DIMMED'); } else if (state === 'hidden') { watchedItem.classList.add('YT-HWV-WATCHED-HIDDEN'); } } if (dimmedItem && (state === 'dimmed' || state === 'hidden')) { dimmedItem.classList.add('YT-HWV-WATCHED-DIMMED'); } }); } catch (error) { console.error('[YT-HWV]', error); } }; // =========================================================== const updateClassOnShortsItems = async () => { try { const section = determineYoutubeSection(); document.querySelectorAll('.YT-HWV-SHORTS-DIMMED').forEach((el) => { el.classList.remove('YT-HWV-SHORTS-DIMMED'); }); document.querySelectorAll('.YT-HWV-SHORTS-HIDDEN').forEach((el) => { el.classList.remove('YT-HWV-SHORTS-HIDDEN'); }); const state = await stateGet(`YTHWV_STATE_SHORTS_${section}`); const shortsContainers = findShortsContainers(); shortsContainers.forEach((item) => { // Add current class if (state === 'dimmed') { item.classList.add('YT-HWV-SHORTS-DIMMED'); } else if (state === 'hidden') { item.classList.add('YT-HWV-SHORTS-HIDDEN'); } }); } catch (error) { console.error('[YT-HWV]', error); } }; // =========================================================== const updateClassOnMixesItems = async () => { try { const section = determineYoutubeSection(); document.querySelectorAll('.YT-HWV-MIXES-DIMMED').forEach((el) => { el.classList.remove('YT-HWV-MIXES-DIMMED'); }); document.querySelectorAll('.YT-HWV-MIXES-HIDDEN').forEach((el) => { el.classList.remove('YT-HWV-MIXES-HIDDEN'); }); const state = await stateGet(`YTHWV_STATE_MIXES_${section}`); const mixesContainers = findMixesContainers(); mixesContainers.forEach((item) => { // Add current class if (state === 'dimmed') { item.classList.add('YT-HWV-MIXES-DIMMED'); } else if (state === 'hidden') { item.classList.add('YT-HWV-MIXES-HIDDEN'); } }); } catch (error) { console.error('[YT-HWV]', error); } }; // =========================================================== // Subscribed-channel hiding (homepage only). // // Unlike watched/shorts/mixes, "subscribed" is not marked on a feed // card, so we build the viewer's own subscription list once (from // /feed/channels), cache it per-account, then match it against the // channel link on each homepage tile. // In-memory set of normalized identifiers (lowercased @handles + UC ids) let subbedSet = null; const applySubbedTintSetting = () => { let pct = 45; try { pct = Number(gmc.get('SUBBED_DIM_BRIGHTNESS')); } catch (_) { /* defaults */ } if (!Number.isFinite(pct)) pct = 45; pct = Math.max(0, Math.min(100, pct)); document.documentElement.style.setProperty( '--ythwv-subbed-brightness', String(pct / 100), ); }; // A per-account key so each signed-in user gets their own cached list const getUserId = () => { const html = document.documentElement.innerHTML; const m = html.match(/"DATASYNC_ID":"([^"|]+)/) || html.match(/"DELEGATED_SESSION_ID":"([^"]+)"/); return m ? m[1] : 'default'; }; const parseSubsFromHtml = (html) => { const ids = new Set(); const handles = new Set(); for (const m of html.matchAll( /"(?:channelId|browseId)":"(UC[0-9A-Za-z_-]{22})"/g, )) { ids.add(m[1]); } for (const m of html.matchAll( /"(?:canonicalBaseUrl|url)":"\\?\/(@[^"\\/]+)"/g, )) { handles.add(m[1].toLowerCase()); } return { ids: [...ids], handles: [...handles] }; }; // Max number of continuation pages to walk when collecting subscriptions const SUBS_FETCH_MAX_PAGES = 60; const matchFirst = (text, re) => { const m = text.match(re); return m ? m[1] : null; }; const getContinuationToken = (text) => { const m = text.match(/"continuationCommand":\{"token":"([^"]+)"/); return m ? m[1] : null; }; // Compute the SAPISIDHASH auth header YouTube's web client uses, so the // continuation requests return the viewer's real subscriptions instead // of a logged-out response. const sapisidHash = async () => { try { const sapisid = ( document.cookie.match(/(?:^|;\s*)SAPISID=([^;]+)/) || document.cookie.match(/(?:^|;\s*)__Secure-3PAPISID=([^;]+)/) )?.[1]; if (!sapisid) return null; const origin = 'https://www.youtube.com'; const ts = Math.floor(Date.now() / 1000); const enc = new TextEncoder().encode(`${ts} ${sapisid} ${origin}`); const buf = await crypto.subtle.digest('SHA-1', enc); const hex = [...new Uint8Array(buf)] .map((b) => b.toString(16).padStart(2, '0')) .join(''); return `SAPISIDHASH ${ts}_${hex}`; } catch (_) { return null; } }; const loadSubs = async (force = false) => { const key = `YTHWV_SUBS_${getUserId()}`; let ttlHours = 24; try { ttlHours = Number(gmc.get('SUBBED_REFRESH_HOURS')) || 24; } catch (_) { /* defaults */ } let cached = null; try { const raw = await stateGet(key, null); if (raw) cached = JSON.parse(raw); } catch (_) { /* ignore */ } const isFresh = cached && Date.now() - cached.updated < ttlHours * 3600 * 1000 && (cached.ids.length || cached.handles.length); if (!force && isFresh) { subbedSet = new Set([...cached.ids, ...cached.handles]); return subbedSet; } try { const ids = new Set(); const handles = new Set(); // /feed/channels lazy-loads, so the initial HTML holds only the // first batch of subscriptions. Parse that, then follow the list's // continuation tokens through the InnerTube API to collect EVERY // subscription -- otherwise channels past the first page never make // it into the set and can't be hidden. const res = await fetch('https://www.youtube.com/feed/channels', { credentials: 'include', }); const html = await res.text(); let parsed = parseSubsFromHtml(html); parsed.ids.forEach((x) => ids.add(x)); parsed.handles.forEach((x) => handles.add(x)); const apiKey = matchFirst(html, /"INNERTUBE_API_KEY":"([^"]+)"/); const clientVersion = matchFirst(html, /"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"/) || matchFirst(html, /"clientVersion":"([^"]+)"/); const auth = await sapisidHash(); let token = getContinuationToken(html); const seen = new Set(); let pages = 0; while ( token && apiKey && auth && !seen.has(token) && pages < SUBS_FETCH_MAX_PAGES ) { seen.add(token); pages += 1; let json; try { const cres = await fetch( `https://www.youtube.com/youtubei/v1/browse?key=${apiKey}&prettyPrint=false`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Authorization: auth, 'X-Origin': 'https://www.youtube.com', 'X-Goog-AuthUser': '0', }, body: JSON.stringify({ context: { client: { clientName: 'WEB', clientVersion: clientVersion || '2.20240101.00.00', hl: 'en', gl: 'US', }, }, continuation: token, }), }, ); if (!cres.ok) break; json = await cres.text(); } catch (_) { break; } const before = ids.size + handles.size; parsed = parseSubsFromHtml(json); parsed.ids.forEach((x) => ids.add(x)); parsed.handles.forEach((x) => handles.add(x)); token = getContinuationToken(json); if (ids.size + handles.size === before && !token) break; } if (ids.size || handles.size) { const out = { updated: Date.now(), ids: [...ids], handles: [...handles], }; await stateSet(key, JSON.stringify(out)); subbedSet = new Set([...out.ids, ...out.handles]); logDebug( `Loaded ${out.ids.length} ids / ${out.handles.length} handles`, ); return subbedSet; } } catch (error) { console.error('[YT-HWV] Failed to fetch subscriptions', error); } // Fall back to whatever we had cached if (cached) subbedSet = new Set([...cached.ids, ...cached.handles]); return subbedSet; }; // Reduce a channel link to a comparable id (@handle or UC id), or null const normalizeChannelHref = (href) => { if (!href) return null; let path = href; try { path = new URL(href, location.origin).pathname; } catch (_) { /* href was already a path */ } path = path.replace(/^\//, ''); if (path.startsWith('@')) return path.split('/')[0].toLowerCase(); const m = path.match(/^channel\/(UC[0-9A-Za-z_-]{22})/); return m ? m[1] : null; }; const findSubscribedContainers = () => { if (!subbedSet || subbedSet.size === 0) return []; const containers = []; document.querySelectorAll('ytd-rich-item-renderer').forEach((tile) => { // Scan the tile's links for the uploader's channel link. // Video/short/mix links normalize to null, so only a real // channel link can match the subscription set. for (const a of tile.querySelectorAll('a[href]')) { const id = normalizeChannelHref(a.getAttribute('href')); if (id && subbedSet.has(id)) { containers.push(tile); break; } } }); return containers; }; const updateClassOnSubscribedItems = async () => { try { document.querySelectorAll('.YT-HWV-SUBBED-DIMMED').forEach((el) => { el.classList.remove('YT-HWV-SUBBED-DIMMED'); }); document.querySelectorAll('.YT-HWV-SUBBED-HIDDEN').forEach((el) => { el.classList.remove('YT-HWV-SUBBED-HIDDEN'); }); // Homepage only -- elsewhere this would nuke whole pages. if (location.pathname !== '/') return; const state = await stateGet('YTHWV_STATE_SUBBED', 'normal'); if (state === 'normal') return; if (!subbedSet) await loadSubs(false); if (!subbedSet || subbedSet.size === 0) return; findSubscribedContainers().forEach((item) => { if (state === 'dimmed') { item.classList.add('YT-HWV-SUBBED-DIMMED'); } else if (state === 'hidden') { item.classList.add('YT-HWV-SUBBED-HIDDEN'); } }); } catch (error) { console.error('[YT-HWV]', error); } }; // =========================================================== const renderButtons = async () => { // Find button area target const target = findButtonAreaTarget(); if (!target) return; // Did we already render the buttons? const existingButtons = document.querySelector('.YT-HWV-BUTTONS'); // Generate buttons area DOM const buttonArea = document.createElement('div'); buttonArea.classList.add('YT-HWV-BUTTONS'); // Render buttons for (const { icon, iconHidden, name, stateKey, type, global } of BUTTONS) { // For toggle buttons, determine where in GM storage they track state. // Most toggles track per-section; "global" toggles share one state. const section = determineYoutubeSection(); const storageKey = stateKey ? global ? stateKey : [stateKey, section].join('_') : null; const toggleButtonState = storageKey ? await stateGet(storageKey, 'normal') : 'normal'; // Generate button DOM const button = document.createElement('button'); button.title = type === 'toggle' ? global ? `${name} : currently "${toggleButtonState}"` : `${name} : currently "${toggleButtonState}" for section "${section}"` : `${name}`; button.classList.add('YT-HWV-BUTTON'); if (toggleButtonState !== 'normal') button.classList.add('YT-HWV-BUTTON-DISABLED'); button.innerHTML = toggleButtonState === 'hidden' ? iconHidden : icon; buttonArea.appendChild(button); // Attach events for toggle buttons switch (type) { case 'toggle': button.addEventListener('click', async () => { logDebug(`Button ${name} clicked. State: ${toggleButtonState}`); let newState = 'dimmed'; if (toggleButtonState === 'dimmed') { newState = 'hidden'; } else if (toggleButtonState === 'hidden') { newState = 'normal'; } await stateSet(storageKey, newState); await updateClassOnWatchedItems(); await updateClassOnShortsItems(); await updateClassOnMixesItems(); await updateClassOnSubscribedItems(); await renderButtons(); }); break; case 'settings': button.addEventListener('click', async () => { gmc.open(); await renderButtons(); }); break; } } // Insert buttons into DOM if (existingButtons) { target.parentNode.replaceChild(buttonArea, existingButtons); logDebug('Re-rendered menu buttons'); } else { target.parentNode.insertBefore(buttonArea, target); logDebug('Rendered menu buttons'); } }; const run = debounce(async (mutations) => { // Don't react if only our own buttons changed state // to avoid running an endless loop if ( mutations && mutations.length === 1 && (mutations[0].target.classList.contains('YT-HWV-BUTTON') || mutations[0].target.classList.contains('YT-HWV-BUTTON-SHORTS')) ) { return; } logDebug('Running check for watched videos, shorts, mixes, and subs'); applySubbedTintSetting(); await updateClassOnWatchedItems(); await updateClassOnShortsItems(); await updateClassOnMixesItems(); await updateClassOnSubscribedItems(); await renderButtons(); }, 250); // =========================================================== // Hijack all XHR calls const send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (data) { this.addEventListener( 'readystatechange', function () { if ( // Anytime more videos are fetched -- re-run script this.responseURL.indexOf('browse_ajax?action_continuation') > 0 ) { setTimeout(() => { run(); }, 0); } }, false, ); send.call(this, data); }; // =========================================================== const observeDOM = (() => { const MutationObserver = window.MutationObserver || window.WebKitMutationObserver; const eventListenerSupported = window.addEventListener; return (obj, callback) => { logDebug('Attaching DOM listener'); // Invalid `obj` given if (!obj) return; if (MutationObserver) { const obs = new MutationObserver((mutations, _observer) => { // If the mutation is the script's own buttons being injected, ignore the event if ( mutations.length === 1 && mutations[0].addedNodes?.length === 1 && mutations[0].addedNodes[0]?.classList?.contains('YT-HWV-BUTTONS') ) { return; } if ( mutations[0].addedNodes.length || mutations[0].removedNodes.length ) { callback(mutations); } }); obs.observe(obj, { childList: true, subtree: true }); } else if (eventListenerSupported) { obj.addEventListener('DOMNodeInserted', callback, false); obj.addEventListener('DOMNodeRemoved', callback, false); } }; })(); // =========================================================== logDebug('Starting Script'); // YouTube does navigation via history and also does a bunch // of AJAX video loading. In order to ensure we're always up // to date, we have to listen for ANY DOM change event, and // re-run our script. observeDOM(document.body, run); run(); })();