// ==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();
})();