YouTube Mute and Skip Ads

Mutes, blurs and skips ads on YouTube. Speeds up ad playback. Clicks "yes" on "are you there?" on YouTube Music.

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

})();