// ==UserScript==
// @name YouTube: Hide Watched Videos
// @namespace https://www.haus.gg/
// @version 6.0
// @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/*
// @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
(function (_undefined) {
// Enable for debugging
const DEBUG = false;
// GM_config setup
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: 'YouTube: Hide Watched Videos Settings',
});
// Set defaults
localStorage.YTHWV_WATCHED = localStorage.YTHWV_WATCHED || 'false';
const logDebug = (...msgs) => {
// eslint-disable-next-line no-console
if (DEBUG) console.log('[YT-HWV]', msgs);
};
// GreaseMonkey no longer supports GM_addStyle. So we have to define
// our own polyfill here
const addStyle = function (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-static-overlay-text-primary);
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-HIDDEN { opacity: 0.4 }
.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 = [{
/* eslint-disable max-len */
icon: '',
iconHidden: '',
/* eslint-enable max-len */
name: 'Toggle Watched Videos',
stateKey: 'YTHWV_STATE',
type: 'toggle',
}, {
/* eslint-disable max-len */
icon: '',
iconHidden: '',
/* eslint-enable max-len */
name: 'Toggle Shorts',
stateKey: 'YTHWV_STATE_SHORTS',
type: 'toggle',
}, {
/* eslint-disable max-len */
icon: '',
/* eslint-enable max-len */
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 = function () {
const watched = document.querySelectorAll('.ytd-thumbnail-overlay-resume-playback-renderer');
const withThreshold = Array.from(watched).filter((bar) => {
return bar.style.width && parseInt(bar.style.width, 10) >= gmc.get('HIDDEN_THRESHOLD_PERCENT');
});
logDebug(
`Found ${watched.length} watched elements ` +
`(${withThreshold.length} within threshold)`
);
return withThreshold;
};
// ===========================================================
const findShortsContainers = function () {
const shortsContainers = [
// 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 = function () {
// Button will be injected into the main header menu
return document.querySelector('#container #end #buttons');
};
// ===========================================================
const determineYoutubeSection = function () {
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 = function () {
// 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') ||
// 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 = function () {
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 = function () {
// 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.classList.add('YT-HWV-BUTTON');
if (toggleButtonState === 'hidden') button.classList.add('YT-HWV-BUTTON-HIDDEN');
button.innerHTML = toggleButtonState === 'normal' ? icon : iconHidden;
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) { return; }
if (mutations[0].target.classList.contains('YT-HWV-BUTTON') ||
mutations[0].target.classList.contains('YT-HWV-BUTTON-SHORTS')) {
return;
}
// something *ELSE* changed state (not our buttons), so keep going
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 = (function () {
const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
const eventListenerSupported = window.addEventListener;
return function (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();
}());