// ==UserScript== // @name YouTube: Hide Watched Videos // @namespace https://www.haus.gg/ // @version 6.9 // @license MIT // @description Hides watched videos (and shorts) from your YouTube subscriptions page. // @author Ev Haus // @author netjeff // @author actionless // @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 // @downloadURL none // ==/UserScript== // To submit bugs or submit revisions please see visit the repository at: // https://github.com/EvHaus/youtube-hide-watched // You can open new issues at: // https://github.com/EvHaus/youtube-hide-watched/issues ((_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, createScriptURL: s, createScript: s, }); } // GM_config setup const title = document.createElement("a"); title.textContent = "YouTube: Hide Watched Videos Settings"; title.href = "https://github.com/EvHaus/youtube-hide-watched"; title.target = "_blank"; const gmc = new GM_config({ events: { save() { this.close(); }, }, fields: { HIDDEN_THRESHOLD_PERCENT: { default: 10, label: "Hide/Dim Videos Above Percent", max: 100, min: 0, type: "int", }, }, id: "YouTubeHideWatchedVideos", title, }); // Set defaults localStorage.YTHWV_WATCHED = localStorage.YTHWV_WATCHED || "false"; const logDebug = (...msgs) => { if (DEBUG) console.debug("[YT-HWV]", msgs); }; // 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-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: var(--yt-spec-icon-inactive); cursor: pointer; display: flex; height: 40px; justify-content: center; outline: 0; width: 40px; } .YT-HWV-BUTTON:focus, .YT-HWV-BUTTON:hover { background: var(--yt-spec-badge-chip-background); } .YT-HWV-BUTTON-DISABLED { color: var(--yt-spec-icon-disabled) } .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: '', 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", // 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") ); }); logDebug( `Found ${watched.length} watched elements ` + `(${withThreshold.length} within threshold)`, ); return withThreshold; }; // =========================================================== 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", ), ].reduce((acc, matches) => { matches?.forEach((child) => { const container = child.closest("ytd-reel-shelf-renderer") || child.closest("ytd-rich-shelf-renderer"); 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"); shortsContainers.push(container); }); logDebug(`Found ${shortsContainers.length} shorts container elements`); return shortsContainers; }; // =========================================================== 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(/.*\/(user|channel|c)\/.+\/videos/u) || href.match(/.*\/@.*/u) ) { 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"; } return youtubeSection; }; // =========================================================== const updateClassOnWatchedItems = () => { // 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 = localStorage[`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"); // 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"); } }); }; // =========================================================== const updateClassOnShortsItems = () => { 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 = localStorage[`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"); } }); }; // =========================================================== const renderButtons = () => { // 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 BUTTONS.forEach(({ icon, iconHidden, name, stateKey, type }) => { // For toggle buttons, determine where in localStorage they track state const section = determineYoutubeSection(); const storageKey = [stateKey, section].join("_"); const toggleButtonState = localStorage.getItem(storageKey) || "normal"; // Generate button DOM const button = document.createElement("button"); button.title = type === "toggle" ? `${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", () => { logDebug(`Button ${name} clicked. State: ${toggleButtonState}`); let newState = "dimmed"; if (toggleButtonState === "dimmed") { newState = "hidden"; } else if (toggleButtonState === "hidden") { newState = "normal"; } localStorage.setItem(storageKey, newState); updateClassOnWatchedItems(); updateClassOnShortsItems(); renderButtons(); }); break; case "settings": button.addEventListener("click", () => { gmc.open(); 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((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, and shorts"); updateClassOnWatchedItems(); updateClassOnShortsItems(); 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 ( 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(); })();