Greasy Fork is available in English.

Auto Close YouTube Ads

Close and/or Mute YouTube ads automatically!

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         Auto Close YouTube Ads
// @namespace    http://fuzetsu.acypa.com
// @version      1.4.5
// @description  Close and/or Mute YouTube ads automatically!
// @author       fuzetsu
// @run-at       document-body
// @match        *://*.youtube.com/*
// @exclude      *://*.youtube.com/subscribe_embed?*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @require      https://cdn.jsdelivr.net/gh/fuzetsu/userscripts@ec863aa92cea78a20431f92e80ac0e93262136df/wait-for-elements/wait-for-elements.js
// @require      https://cdn.jsdelivr.net/gh/kufii/My-UserScripts@23586fd0a72b587a1786f7bb9088e807a5b53e79/libs/gm_config.js
// ==/UserScript==
/* globals GM_getValue GM_setValue GM_deleteValue GM_registerMenuCommand GM_config waitForElems waitForUrl */
/**
 * This section of the code holds the css selectors that point different parts of YouTube's
 * user interface. If the script ever breaks and you don't want to wait for me to fix it
 * chances are that it can be fixed by just updating these selectors here.
 */
const CSS = {
  // the button used to skip an ad
  skipButton: '.videoAdUiSkipButton,.ytp-ad-skip-button,.ytp-ad-skip-button-modern',
  // the area showing the countdown to the skip button showing
  preSkipButton: '.videoAdUiPreSkipButton,.ytp-ad-preview-container',
  // little x that closes banner ads
  closeBannerAd: '.close-padding.contains-svg,a.close-button,.ytp-ad-overlay-close-button',
  // button that toggle mute on the video
  muteButton: '.ytp-mute-button',
  // the slider bar handle that represents the current volume
  muteIndicator: '.ytp-volume-slider-handle',
  // container for ad on video
  adArea: '.videoAdUi,.ytp-ad-player-overlay',
  // container that shows ad length eg 3:23
  adLength: '.videoAdUiAttribution,.ytp-ad-duration-remaining',
  // container for header ad on the home page
  homeAdContainer: '#masthead-ad'
}

const util = {
  log: (...args) => console.log(`%c${SCRIPT_NAME}:`, 'font-weight: bold;color: purple;', ...args),
  clearTicks: ticks => {
    ticks.forEach(tick =>
      !tick ? null : typeof tick === 'number' ? clearInterval(tick) : tick.stop()
    )
    ticks.length = 0
  },
  keepTrying: (wait, action) => {
    const tick = setInterval(() => action() && clearInterval(tick), wait)
    return tick
  },
  storeGet: key => {
    if (typeof GM_getValue === 'undefined') {
      const value = localStorage.getItem(key)
      return value === 'true' ? true : value === 'false' ? false : value
    }
    return GM_getValue(key)
  },
  storeSet: (key, value) =>
    typeof GM_setValue === 'undefined' ? localStorage.setItem(key, value) : GM_setValue(key, value),
  storeDel: key =>
    typeof GM_deleteValue === 'undefined' ? localStorage.removeItem(key) : GM_deleteValue(key),
  q: (query, context) => (context || document).querySelector(query),
  qq: (query, context) => Array.from((context || document).querySelectorAll(query)),
  get: (obj, str) => util.getPath(obj, str.split('.').reverse()),
  getPath: (obj, path) =>
    obj == null ? null : path.length > 0 ? util.getPath(obj[path.pop()], path) : obj
}

const SCRIPT_NAME = 'Auto Close YouTube Ads'
const SHORT_AD_MSG_LENGTH = 12000
const TICKS = []
let DONT_SKIP = false

const config = GM_config([
  {
    key: 'muteAd',
    label: 'Mute ads?',
    type: 'bool',
    default: true
  },
  {
    key: 'hideAd',
    label: 'Hide video ads?',
    type: 'bool',
    default: false
  },
  {
    key: 'secWaitBanner',
    label: 'Banner ad close delay (seconds)',
    type: 'number',
    default: 3,
    min: 0
  },
  {
    key: 'secWaitVideo',
    label: 'Video ad skip delay (seconds)',
    type: 'number',
    default: 3,
    min: 0
  },
  {
    key: 'minAdLengthForSkip',
    label: 'Dont skip video shorter than this (seconds)',
    type: 'number',
    default: 0,
    min: 0
  },
  {
    key: 'muteEvenIfNotSkipping',
    label: 'Mute video even if not skipping',
    type: 'bool',
    default: true
  },
  {
    key: 'debug',
    label: 'Show extra debug information.',
    type: 'bool',
    default: false
  },
  {
    key: 'version',
    type: 'hidden',
    default: 1
  }
])

const configVersion = 2
let conf = config.load()

config.onsave = cfg => (conf = cfg)

// config upgrade procedure
function upgradeConfig() {
  let lastVersion
  while (conf.version < configVersion && lastVersion !== conf.version) {
    util.log('upgrading config version, current = ', conf.version, ', target = ', configVersion)
    lastVersion = conf.version
    switch (conf.version) {
      case 1: {
        const oldConf = {
          muteAd: util.storeGet('MUTE_AD'),
          hideAd: util.storeGet('HIDE_AD'),
          secWait: util.storeGet('SEC_WAIT')
        }

        if (oldConf.muteAd != null) conf.muteAd = !!oldConf.muteAd
        if (oldConf.hideAd != null) conf.hideAd = !!oldConf.hideAd
        if (oldConf.secWait != null && !isNaN(oldConf.secWait))
          conf.secWaitBanner = conf.secWaitVideo = parseInt(oldConf.secWait)

        conf.version = 2

        config.save(conf)
        ;['SEC_WAIT', 'HIDE_AD', 'MUTE_AD'].forEach(util.storeDel)
        break
      }
    }
  }
}
upgradeConfig()

function createMessageElement() {
  const elem = document.createElement('div')
  elem.setAttribute(
    'style',
    'border: 1px solid white;border-right: none;background: rgb(0,0,0,0.75);color:white;position: absolute;right: 0;z-index: 1000;top: 30px;padding: 10px;padding-right: 20px;cursor: pointer;pointer-events: all;'
  )
  return elem
}
function showMessage(container, text, ms) {
  const message = createMessageElement()
  message.textContent = text
  container.appendChild(message)
  util.log(`showing message [${ms}ms]: ${text}`)
  setTimeout(() => message.remove(), ms)
}

function setupCancelDiv(ad) {
  const skipArea = util.q(CSS.preSkipButton, ad)
  const skipText = skipArea && skipArea.textContent.trim().replace(/\s+/g, ' ')
  if (skipText && !['will begin', 'will play'].some(snip => skipText.includes(snip))) {
    const cancelClass = 'acya-cancel-skip'
    let cancelDiv = util.q('.' + cancelClass)
    if (cancelDiv) cancelDiv.remove()
    cancelDiv = createMessageElement()
    cancelDiv.className = cancelClass
    cancelDiv.textContent = (conf.muteAd ? 'Un-mute & ' : '') + 'Cancel Auto Skip'
    cancelDiv.onclick = () => {
      util.log('cancel clicked')
      DONT_SKIP = true
      cancelDiv.remove()
      if (conf.hideAd) {
        ad.style.zIndex = ''
        ad.style.background = ''
      }
      const muteButton = getMuteButton()
      const muteIndicator = getMuteIndicator()
      if (conf.muteAd && muteButton && muteIndicator && isMuted(muteIndicator)) muteButton.click()
    }
    ad.appendChild(cancelDiv)
  } else {
    util.log("skip button area wasn't there for some reason.. couldn't place cancel button.")
  }
}

function parseTime(str) {
  const [minutes, seconds] = str
    .split(' ')
    .pop()
    .split(':')
    .map(num => parseInt(num))
  util.log(str, minutes, seconds)
  return minutes * 60 + seconds || 0
}

const getMuteButton = () => util.qq(CSS.muteButton).find(elem => elem.offsetParent)
const getMuteIndicator = () => util.qq(CSS.muteIndicator).find(elem => elem.offsetParent)
const isMuted = m => m.style.left === '0px'

function getAdLength(ad) {
  if (!ad) return 0
  const time = ad.querySelector(CSS.adLength)
  return time ? parseTime(time.textContent) : 0
}

function waitForAds() {
  DONT_SKIP = false
  TICKS.push(
    waitForElems({
      sel: CSS.skipButton,
      onmatch: btn => {
        util.log('found skip button')
        util.keepTrying(500, () => {
          if (!btn) return true
          // if not visible
          if (btn.offsetParent == null) return
          setTimeout(() => {
            if (DONT_SKIP) {
              util.log('not skipping...')
              DONT_SKIP = false
              return
            }
            util.log('clicking skip button')
            btn.click()
          }, conf.secWaitVideo * 1000)
          return true
        })
      }
    }),
    waitAndClick(CSS.closeBannerAd, conf.secWaitBanner * 1000),
    waitForElems({
      sel: CSS.adArea,
      onmatch: ad => {
        // reset don't skip
        DONT_SKIP = false
        const adLength = getAdLength(ad)
        const isShort = adLength < conf.minAdLengthForSkip
        const debug = () =>
          conf.debug
            ? `[DEBUG adLength = ${adLength}, minAdLengthForSkip = ${conf.minAdLengthForSkip}]`
            : ''
        if (isShort && !conf.muteEvenIfNotSkipping) {
          DONT_SKIP = true
          return showMessage(
            ad,
            `Shot AD detected, will not skip or mute. ${debug()}`,
            SHORT_AD_MSG_LENGTH
          )
        }
        if (conf.hideAd) {
          ad.style.zIndex = 10
          ad.style.background = 'black'
        }
        // show option to cancel automatic skip
        if (!isShort) setupCancelDiv(ad)
        if (!conf.muteAd) return
        const muteButton = getMuteButton()
        const muteIndicator = getMuteIndicator()
        if (!muteIndicator) return util.log('unable to determine mute state, skipping mute')
        muteButton.click()
        util.log('Video ad detected, muting audio')
        // wait for the ad to disappear before unmuting
        util.keepTrying(250, () => {
          if (!util.q(CSS.adArea)) {
            if (isMuted(muteIndicator)) {
              muteButton.click()
              util.log('Video ad ended, unmuting audio')
            } else {
              util.log('Video ad ended, audio already unmuted')
            }
            return true
          }
        })
        if (isShort) {
          DONT_SKIP = true
          return showMessage(
            ad,
            `Short AD detected, will not skip but will mute. ${debug()}`,
            SHORT_AD_MSG_LENGTH
          )
        }
      }
    })
  )
}

const waitAndClick = (sel, ms, cb) =>
  waitForElems({
    sel: sel,
    onmatch: btn => {
      util.log('Found ad, closing in', ms, 'ms')
      setTimeout(() => {
        btn.click()
        if (cb) cb(btn)
      }, ms)
    }
  })

util.log('Started')

if (window.self === window.top) {
  let videoUrl
  // close home ad whenever encountered
  waitForElems({ sel: CSS.homeAdContainer, onmatch: ad => ad.remove() })
  // wait for video page
  waitForUrl(/^https:\/\/www\.youtube\.com\/watch\?.*v=.+/, () => {
    if (videoUrl && location.href !== videoUrl) {
      util.log('Changed video, removing old wait')
      util.clearTicks(TICKS)
    }
    videoUrl = location.href
    util.log('Entered video, waiting for ads')
    waitForAds()
    TICKS.push(
      waitForUrl(
        url => url !== videoUrl,
        () => {
          videoUrl = null
          util.clearTicks(TICKS)
          util.log('Left video, stopped waiting for ads')
        },
        true
      )
    )
  })
} else {
  if (/^https:\/\/www\.youtube\.com\/embed\//.test(location.href)) {
    util.log('Found embedded video, waiting for ads')
    waitForAds()
  }
}

GM_registerMenuCommand('Auto Close Youtube Ads - Manage Settings', config.setup)