// ==UserScript==
// @name YouTube Mute and Skip Ads
// @namespace https://github.com/ion1/userscripts
// @version 0.0.28
// @author ion
// @description Mutes, blurs and skips ads on YouTube. Speeds up ad playback. Clicks "yes" on "are you there?" on YouTube Music.
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @homepage https://github.com/ion1/userscripts/tree/master/packages/youtube-mute-skip-ads
// @homepageURL https://github.com/ion1/userscripts/tree/master/packages/youtube-mute-skip-ads
// @match *://www.youtube.com/*
// @match *://music.youtube.com/*
// @grant GM_addStyle
// @run-at document-body
// ==/UserScript==
(n=>{if(typeof GM_addStyle=="function"){GM_addStyle(n);return}const e=document.createElement("style");e.textContent=n,document.head.append(e)})(` /* Keep these in sync with the watchers. */
#movie_player
:is(.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button) {
anchor-name: --youtube-mute-skip-ads-unclickable-button;
}
body:has(
#movie_player
:is(
.ytp-ad-skip-button,
.ytp-ad-skip-button-modern,
.ytp-skip-ad-button
):not([style*="display: none"], [aria-hidden="true"])
)::after {
content: "\u{1D606}\u{1D5FC}\u{1D602}\u{1D601}\u{1D602}\u{1D5EF}\u{1D5F2}-\u{1D5FA}\u{1D602}\u{1D601}\u{1D5F2}-\u{1D600}\u{1D5F8}\u{1D5F6}\u{1D5FD}-\u{1D5EE}\u{1D5F1}\u{1D600}\\A\\A"
"Unfortunately, YouTube has started to block automated clicks based on isTrusted being false.\\A\\A"
"Please click on the skip button manually.";
white-space: pre-line;
pointer-events: none;
z-index: 9999;
position: fixed;
position-anchor: --youtube-mute-skip-ads-unclickable-button;
padding: 1.5em;
border-radius: 1.5em;
margin-bottom: 1em;
bottom: anchor(--youtube-mute-skip-ads-unclickable-button top);
right: anchor(--youtube-mute-skip-ads-unclickable-button right);
max-width: 25em;
font-size: 1.4rem;
line-height: 2rem;
font-weight: 400;
color: rgb(240 240 240);
background-color: rgb(0 0 0 / 0.7);
backdrop-filter: blur(10px);
animation: fade-in 3s linear;
}
@keyframes fade-in {
0% {
opacity: 0;
}
67% {
opacity: 0;
}
100% {
opacity: 1;
}
}
#movie_player.ad-showing video {
filter: blur(100px) opacity(0.25) grayscale(0.5);
}
#movie_player.ad-showing .ytp-title,
#movie_player.ad-showing .ytp-title-channel,
.ytp-visit-advertiser-link,
.ytp-ad-visit-advertiser-button,
ytmusic-app:has(#movie_player.ad-showing)
ytmusic-player-bar
:is(.title, .subtitle) {
filter: blur(4px) opacity(0.5) grayscale(0.5);
transition: 0.05s filter linear;
}
:is(#movie_player.ad-showing .ytp-title,#movie_player.ad-showing .ytp-title-channel,.ytp-visit-advertiser-link,.ytp-ad-visit-advertiser-button,ytmusic-app:has(#movie_player.ad-showing) ytmusic-player-bar :is(.title,.subtitle)):is(:hover,:focus-within) {
filter: none;
}
/* These popups are showing up on top of the video with a hidden dismiss button
* since 2024-09-25.
*/
.ytp-suggested-action-badge {
visibility: hidden !important;
}
#movie_player.ad-showing .caption-window,
.ytp-ad-player-overlay-flyout-cta,
.ytp-ad-player-overlay-layout__player-card-container, /* Seen since 2024-04-06. */
.ytp-ad-action-interstitial-slot, /* Added on 2024-08-25. */
ytd-action-companion-ad-renderer,
ytd-display-ad-renderer,
ytd-ad-slot-renderer,
ytd-promoted-sparkles-web-renderer,
ytd-player-legacy-desktop-watch-ads-renderer,
ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"],
ytd-merch-shelf-renderer {
filter: blur(10px) opacity(0.25) grayscale(0.5);
transition: 0.05s filter linear;
}
:is(#movie_player.ad-showing .caption-window,.ytp-ad-player-overlay-flyout-cta,.ytp-ad-player-overlay-layout__player-card-container,.ytp-ad-action-interstitial-slot,ytd-action-companion-ad-renderer,ytd-display-ad-renderer,ytd-ad-slot-renderer,ytd-promoted-sparkles-web-renderer,ytd-player-legacy-desktop-watch-ads-renderer,ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"],ytd-merch-shelf-renderer):is(:hover,:focus-within) {
filter: none;
}
.ytp-ad-action-interstitial-background-container /* Added on 2024-08-25. */ {
/* An image ad in place of the video. */
filter: blur(10px) opacity(0.25) grayscale(0.5);
} `);
(function () {
'use strict';
const logPrefix = "youtube-mute-skip-ads:";
class Watcher {
name;
element;
#onCreatedCallbacks;
#onRemovedCallbacks;
#nodeObserver;
#nodeWatchers;
#textObserver;
#onTextChangedCallbacks;
#onAttrChangedCallbacks;
visibilityAncestor;
#visibilityObserver;
#isVisible;
#visibilityWatchers;
constructor(name, elem) {
this.name = name;
this.element = null;
this.#onCreatedCallbacks = [];
this.#onRemovedCallbacks = [];
this.#nodeObserver = null;
this.#nodeWatchers = [];
this.#textObserver = null;
this.#onTextChangedCallbacks = [];
this.#onAttrChangedCallbacks = [];
this.visibilityAncestor = null;
this.#visibilityObserver = null;
this.#isVisible = null;
this.#visibilityWatchers = [];
if (elem != null) {
this.#connect(elem);
}
}
#assertElement() {
if (this.element == null) {
throw new Error(`Watcher not connected to an element`);
}
return this.element;
}
#assertVisibilityAncestor() {
if (this.visibilityAncestor == null) {
throw new Error(`Watcher is missing a visibilityAncestor`);
}
return this.visibilityAncestor;
}
#connect(element, visibilityAncestor) {
if (this.element != null) {
if (this.element !== element) {
console.error(
logPrefix,
`Watcher already connected to`,
this.element,
`while trying to connect to`,
element
);
}
return;
}
this.element = element;
this.visibilityAncestor = visibilityAncestor ?? null;
for (const onCreatedCb of this.#onCreatedCallbacks) {
const onRemovedCb = onCreatedCb(this.element);
if (onRemovedCb) {
this.#onRemovedCallbacks.push(onRemovedCb);
}
}
for (const { selector, name, watcher: watcher2 } of this.#nodeWatchers) {
for (const descElem of getDescendantsBy(this.element, selector, name)) {
watcher2.#connect(descElem, this.element);
}
}
for (const callback of this.#onTextChangedCallbacks) {
callback(this.element, this.element.textContent);
}
for (const { name, callback } of this.#onAttrChangedCallbacks) {
callback(this.element, this.element.getAttribute(name));
}
this.#registerNodeObserver();
this.#registerTextObserver();
this.#registerAttrObservers();
this.#registerVisibilityObserver();
}
#disconnect() {
if (this.element == null) {
return;
}
for (const child of this.#nodeWatchers) {
child.watcher.#disconnect();
}
for (const callback of this.#onTextChangedCallbacks) {
callback(this.element, void 0);
}
for (const { callback } of this.#onAttrChangedCallbacks) {
callback(this.element, void 0);
}
for (const child of this.#visibilityWatchers) {
child.#disconnect();
}
this.#deregisterNodeObserver();
this.#deregisterTextObserver();
this.#deregisterAttrObservers();
this.#deregisterVisibilityObserver();
while (this.#onRemovedCallbacks.length > 0) {
const onRemovedCb = this.#onRemovedCallbacks.shift();
onRemovedCb();
}
this.element = null;
}
#registerNodeObserver() {
if (this.#nodeObserver != null) {
return;
}
if (this.#nodeWatchers.length === 0) {
return;
}
const elem = this.#assertElement();
this.#nodeObserver = new MutationObserver((mutations) => {
for (const mut of mutations) {
for (const node of mut.addedNodes) {
for (const { selector, name, watcher: watcher2 } of this.#nodeWatchers) {
for (const descElem of getSelfOrDescendantsBy(
node,
selector,
name
)) {
watcher2.#connect(descElem, elem);
}
}
}
for (const node of mut.removedNodes) {
for (const { selector, name, watcher: watcher2 } of this.#nodeWatchers) {
for (const _descElem of getSelfOrDescendantsBy(
node,
selector,
name
)) {
watcher2.#disconnect();
}
}
}
}
});
this.#nodeObserver.observe(elem, {
subtree: true,
childList: true
});
}
#registerTextObserver() {
if (this.#textObserver != null) {
return;
}
if (this.#onTextChangedCallbacks.length === 0) {
return;
}
const elem = this.#assertElement();
this.#textObserver = new MutationObserver((_mutations) => {
for (const callback of this.#onTextChangedCallbacks) {
callback(elem, elem.textContent);
}
});
this.#textObserver.observe(elem, {
subtree: true,
// This is needed when elements are replaced to update their text.
childList: true,
characterData: true
});
}
#registerAttrObservers() {
const elem = this.#assertElement();
for (const handler of this.#onAttrChangedCallbacks) {
if (handler.observer != null) {
continue;
}
const { name, callback } = handler;
handler.observer = new MutationObserver((_mutations) => {
callback(elem, elem.getAttribute(name));
});
handler.observer.observe(elem, {
attributes: true,
attributeFilter: [name]
});
}
}
#registerVisibilityObserver() {
if (this.#visibilityObserver != null) {
return;
}
if (this.#visibilityWatchers.length === 0) {
return;
}
this.#isVisible = false;
const elem = this.#assertElement();
const visibilityAncestor = this.#assertVisibilityAncestor();
this.#visibilityObserver = new IntersectionObserver(
(entries) => {
const oldVisible = this.#isVisible;
for (const entry of entries) {
this.#isVisible = entry.isIntersecting;
}
if (this.#isVisible !== oldVisible) {
if (this.#isVisible) {
for (const watcher2 of this.#visibilityWatchers) {
watcher2.#connect(elem, visibilityAncestor);
}
} else {
for (const watcher2 of this.#visibilityWatchers) {
watcher2.#disconnect();
}
}
}
},
{
root: visibilityAncestor
}
);
this.#visibilityObserver.observe(elem);
}
#deregisterNodeObserver() {
if (this.#nodeObserver == null) {
return;
}
this.#nodeObserver.disconnect();
this.#nodeObserver = null;
}
#deregisterTextObserver() {
if (this.#textObserver == null) {
return;
}
this.#textObserver.disconnect();
this.#textObserver = null;
}
#deregisterAttrObservers() {
for (const handler of this.#onAttrChangedCallbacks) {
if (handler.observer == null) {
continue;
}
handler.observer.disconnect();
handler.observer = null;
}
}
#deregisterVisibilityObserver() {
if (this.#visibilityObserver == null) {
return;
}
this.#visibilityObserver.disconnect();
this.#visibilityObserver = null;
this.#isVisible = null;
}
onCreated(onCreatedCb) {
this.#onCreatedCallbacks.push(onCreatedCb);
if (this.element != null) {
const onRemovedCb = onCreatedCb(this.element);
if (onRemovedCb) {
this.#onRemovedCallbacks.push(onRemovedCb);
}
}
return this;
}
descendant(selector, name) {
const watcher2 = new Watcher(`${this.name} → ${name}`);
this.#nodeWatchers.push({ selector, name, watcher: watcher2 });
if (this.element != null) {
for (const descElem of getDescendantsBy(this.element, selector, name)) {
watcher2.#connect(descElem, this.element);
}
this.#registerNodeObserver();
}
return watcher2;
}
id(idName) {
return this.descendant("id", idName);
}
klass(className) {
return this.descendant("class", className);
}
tag(tagName) {
return this.descendant("tag", tagName);
}
visible() {
const watcher2 = new Watcher(`${this.name} (visible)`);
this.#visibilityWatchers.push(watcher2);
if (this.element != null) {
const visibilityAncestor = this.#assertVisibilityAncestor();
if (this.#isVisible) {
watcher2.#connect(this.element, visibilityAncestor);
}
this.#registerVisibilityObserver();
}
return watcher2;
}
/// `null` implies null textContent. `undefined` implies that the watcher is
/// being disconnected.
text(callback) {
this.#onTextChangedCallbacks.push(callback);
if (this.element != null) {
callback(this.element, this.element.textContent);
this.#registerTextObserver();
}
return this;
}
/// `null` implies no such attribute. `undefined` implies that the watcher is
/// being disconnected.
attr(name, callback) {
this.#onAttrChangedCallbacks.push({ name, callback, observer: null });
if (this.element != null) {
callback(this.element, this.element.getAttribute(name));
this.#registerAttrObservers();
}
return this;
}
}
function getSelfOrDescendantsBy(node, selector, name) {
if (!(node instanceof HTMLElement)) {
return [];
}
if (selector === "id" || selector === "class" || selector === "tag") {
if (selector === "id" && node.id === name || selector === "class" && node.classList.contains(name) || selector === "tag" && node.tagName.toLowerCase() === name.toLowerCase()) {
return [node];
} else {
return getDescendantsBy(node, selector, name);
}
} else {
const impossible = selector;
throw new Error(`Impossible selector type: ${JSON.stringify(impossible)}`);
}
}
function getDescendantsBy(node, selector, name) {
if (!(node instanceof HTMLElement)) {
return [];
}
let cssSelector = "";
if (selector === "id") {
cssSelector += "#";
} else if (selector === "class") {
cssSelector += ".";
} else if (selector === "tag") ;
else {
const impossible = selector;
throw new Error(`Impossible selector type: ${JSON.stringify(impossible)}`);
}
cssSelector += CSS.escape(name);
return Array.from(node.querySelectorAll(cssSelector));
}
const videoSelector = "#movie_player video";
function getVideoElement() {
const videoElem = document.querySelector(videoSelector);
if (!(videoElem instanceof HTMLVideoElement)) {
console.error(
logPrefix,
"Expected",
JSON.stringify(videoSelector),
"to be a video element, got:",
videoElem?.cloneNode(true)
);
return null;
}
return videoElem;
}
function callMoviePlayerMethod(name, onSuccess, args) {
try {
const movieElem = document.getElementById("movie_player");
if (movieElem == null) {
console.warn(logPrefix, "movie_player element not found");
return;
}
const method = Object.getOwnPropertyDescriptor(
movieElem,
name
)?.value;
if (method == null) {
console.warn(
logPrefix,
`movie_player element has no ${JSON.stringify(name)} property`
);
return;
}
if (!(typeof method === "function")) {
console.warn(
logPrefix,
`movie_player element property ${JSON.stringify(name)} is not a function`
);
return;
}
const result = method.apply(movieElem, args);
if (onSuccess != null) {
onSuccess(result);
}
return result;
} catch (e) {
console.warn(
logPrefix,
`movie_player method ${JSON.stringify(name)} failed:`,
e
);
return;
}
}
function disableVisibilityChecks() {
for (const eventName of ["visibilitychange", "blur", "focus"]) {
document.addEventListener(
eventName,
(ev) => {
ev.stopImmediatePropagation();
},
{ capture: true }
);
}
document.hasFocus = () => true;
Object.defineProperties(document, {
visibilityState: { value: "visible" },
hidden: { value: false }
});
}
function adIsPlaying(_elem) {
console.info(logPrefix, "An ad is playing, muting and speeding up");
const video = getVideoElement();
if (video == null) {
return;
}
const onRemovedCallbacks = [
mute(video),
speedup(video),
cancelPlayback(video)
];
return function onRemoved() {
for (const callback of onRemovedCallbacks) {
callback();
}
};
}
function mute(video) {
console.debug(logPrefix, "Muting");
video.muted = true;
return unmute;
}
function unmute() {
{
return;
}
}
function speedup(video) {
for (let rate = 16; rate >= 2; rate /= 2) {
try {
video.playbackRate = rate;
break;
} catch (e) {
console.debug(logPrefix, `Setting playback rate to`, rate, `failed:`, e);
}
}
return function onRemoved() {
const originalRate = callMoviePlayerMethod("getPlaybackRate");
if (originalRate == null || typeof originalRate !== "number" || isNaN(originalRate)) {
console.warn(
logPrefix,
`Restoring playback rate failed:`,
`unable to query the current playback rate, got: ${JSON.stringify(originalRate)}.`,
`Falling back to 1.`
);
restorePlaybackRate(video, 1);
return;
}
restorePlaybackRate(video, originalRate);
};
}
function restorePlaybackRate(video, originalRate) {
try {
video.playbackRate = originalRate;
} catch (e) {
console.debug(
logPrefix,
`Restoring playback rate to`,
originalRate,
`failed:`,
e
);
}
}
function cancelPlayback(video) {
let shouldResume = false;
function doCancelPlayback() {
console.info(logPrefix, "Attempting to cancel playback");
callMoviePlayerMethod("cancelPlayback", () => {
shouldResume = true;
});
}
if (video.paused) {
console.debug(
logPrefix,
"Ad paused, waiting for it to play before canceling playback"
);
video.addEventListener("play", doCancelPlayback);
} else {
doCancelPlayback();
}
return function onRemoved() {
video.removeEventListener("play", doCancelPlayback);
if (shouldResume) {
resumePlaybackIfNotAtEnd();
}
};
}
function resumePlaybackIfNotAtEnd() {
const currentTime = callMoviePlayerMethod("getCurrentTime");
const duration = callMoviePlayerMethod("getDuration");
const isAtLiveHead = callMoviePlayerMethod("isAtLiveHead");
if (currentTime == null || duration == null || typeof currentTime !== "number" || typeof duration !== "number" || isNaN(currentTime) || isNaN(duration)) {
console.warn(
logPrefix,
`movie_player methods getCurrentTime/getDuration failed, got time: ${JSON.stringify(currentTime)}, duration: ${JSON.stringify(duration)}`
);
return;
}
if (isAtLiveHead == null || typeof isAtLiveHead !== "boolean") {
console.warn(
logPrefix,
`movie_player method isAtLiveHead failed, got: ${JSON.stringify(isAtLiveHead)}`
);
return;
}
const atEnd = duration - currentTime < 1;
if (atEnd && !isAtLiveHead) {
console.info(
logPrefix,
`Video is at the end (${currentTime}/${duration}), not attempting to resume playback`
);
return;
}
console.info(logPrefix, "Attempting to resume playback");
callMoviePlayerMethod("playVideo");
}
function click(description) {
return (elem) => {
if (elem.getAttribute("aria-hidden")) {
console.info(logPrefix, "Not clicking (aria-hidden):", description);
} else {
console.info(logPrefix, "Clicking:", description);
elem.click();
}
};
}
disableVisibilityChecks();
const watcher = new Watcher("body", document.body);
const adPlayerOverlayClasses = [
"ytp-ad-player-overlay",
"ytp-ad-player-overlay-layout"
// Seen since 2024-04-06.
];
for (const adPlayerOverlayClass of adPlayerOverlayClasses) {
watcher.klass(adPlayerOverlayClass).onCreated(adIsPlaying);
}
const adSkipButtonClasses = [
"ytp-ad-skip-button",
"ytp-ad-skip-button-modern",
// Seen since 2023-11-10.
"ytp-skip-ad-button"
// Seen since 2024-04-06.
];
for (const adSkipButtonClass of adSkipButtonClasses) {
watcher.id("movie_player").klass(adSkipButtonClass).visible().attr("aria-hidden", (elem, value) => {
if (value === null) {
click(`skip (${adSkipButtonClass})`)(elem);
}
});
}
watcher.klass("ytp-ad-overlay-close-button").visible().onCreated(click("overlay close"));
watcher.tag("ytmusic-you-there-renderer").tag("button").visible().onCreated(click("are-you-there"));
})();