Twitch Ad Skipper

Script to skip ad placeholder (i.e. purple screen of doom when ads are blocked)

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name          Twitch Ad Skipper
// @namespace     https://www.twitch.tv/
// @version       2.2
// @description   Script to skip ad placeholder (i.e. purple screen of doom when ads are blocked)
// @author        simple-hacker & Wilkolicious
// @match         https://www.twitch.tv/*
// @grant         none
// @require       https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.11.0/underscore-min.js
// @homepageURL   https://github.com/Wilkolicious/twitchAdSkip
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const scriptName = 'twitchAdSkip';
  const adTestSel = '[data-test-selector="ad-banner-default-text"]';
  const ffzResetBtnSel = '[data-a-target="ffz-player-reset-button"]';
  const videoPlayerSel = '[data-a-target="video-player"]';
  const videoPlayervolSliderSel = '[data-a-target="player-volume-slider"]';
  const videoNodeSel = 'video';
  const postFixVolWaitTime = 2000;
  const nodeTypesToCheck = [Node.ELEMENT_NODE, Node.DOCUMENT_NODE, Node.DOCUMENT_FRAGMENT_NODE];

  //
  const maxRetriesFindVideoPlayer = 5;
  const maxRetriesVolListener = 5;
  const maxRetriesVideoPlayerObserver = 5;

  // Volume vals
  let videoNodeVolCurrent;
  let adLaunched = false;

  // Helpers //
  const log = function (logType, message) {
    return console[logType](`${scriptName}: ${message}`);
  };
  const getFfzResetButton = function () {
    return document.querySelector(ffzResetBtnSel);
  };
  const getElWithOptContext = function (selStr, context) {
    context = context || document;
    return context.querySelector(selStr);
  };
  const getVideoNodeEl = function (context) {
    return getElWithOptContext(videoNodeSel, context);
  };
  const getVideoPlayerVolSliderEl = function (context) {
    return getElWithOptContext(videoPlayervolSliderSel, context);
  };
  const getVideoPlayerEl = function (context) {
    return getElWithOptContext(videoPlayerSel, context);
  };

  const attachMO = async function (videoPlayerEl) {
    let resetButton = getFfzResetButton();

    const videoPlayerObserver = new MutationObserver(function (mutations) {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          const canCheckNode = nodeTypesToCheck.includes(node.nodeType);
          if (!canCheckNode) {
            continue;
          }

          const isAdNode = node.querySelector(adTestSel);
          if (!isAdNode) {
            continue;
          }

          log('info', `Found ad node at: ${adTestSel}`);

          // Is ad //
          adLaunched = true;
          if (!resetButton) {
            log('info', `FFZ reset button not loaded - attempting to load...`);

            // Attempt to load the resetButton now
            resetButton = getFfzResetButton();

            if (!resetButton) {
              log('error', `FFZ reset button could not be loaded - refreshing full page.`);

              // Not loaded for some reason
              window.location.reload();
            }
          }

          // Cache current vol props //
          log('info', 'Finding video node to post-fix volume.');
          // Actual video volume
          const videoNodeEl = getVideoNodeEl(videoPlayerEl);
          log('info', `Volume before reset: ${videoNodeVolCurrent}`);

          // Cosmetic vol slider
          const videoPlayerVolSliderEl = getVideoPlayerVolSliderEl(videoPlayerEl);
          const videoPlayerVolSliderCurrent = parseInt(videoPlayerVolSliderEl.value, 10).toFixed(2);

          log('info', `Triggering FFZ reset button...`);
          resetButton.dispatchEvent(new MouseEvent('dblclick', {
            bubbles: true,
            cancelable: true,
            view: window
          }));d

          log('info', `Fixing volume to original value of '${videoNodeVolCurrent}' after interval of '${postFixVolWaitTime}' ms`);
          setTimeout(() => {
            // Does the video player element still exist after reset?
            if (!videoPlayerEl) {
              log('info', 'Video player element destroyed after reset - sourcing new element...');
              videoPlayerEl = getVideoPlayerEl();
            }

            // Does the video node still exist after reset?
            if (!videoNodeEl) {
              log('info', 'Video node destroyed after reset - sourcing new node...');
              videoNodeEl = getVideoNodeEl(videoPlayerEl);
            }

            // Fix video vol
            const preFixVol = videoNodeEl.volume;
            videoNodeEl.volume = videoNodeVolCurrent;
            log('info', `Post-fixed volume from reset val of '${preFixVol}' -> '${videoNodeVolCurrent}'`);

            // Fix video player vol slider
            // TODO: this may not work due to this input being tied to the js framework component
            if (!videoPlayerVolSliderEl) {
              videoPlayerVolSliderEl = getVideoPlayerVolSliderEl(videoPlayerEl);
            }
            videoPlayerVolSliderEl.value = videoPlayerVolSliderCurrent;

            adLaunched = false;
          }, postFixVolWaitTime);
        }
      }
    });

    videoPlayerObserver.observe(videoPlayerEl, {
      childList: true,
      subtree: true
    });
    log('info', 'Video player observer attached');
  };

  const listenForVolumeChanges = async function (videoPlayerEl) {
    const videoNodeEl = getVideoNodeEl(videoPlayerEl);

    if (!videoNodeEl) {
      throw new Error('Video player element not found.  If it is expected that there is no video on the current page (e.g. Twitch directory), then ignore this error.');
    }

    // Initial load val
    videoNodeVolCurrent = videoNodeEl.volume.toFixed(2);
    log('info', `Initial volume: '${videoNodeVolCurrent}'.`);

    const videoPlayerVolSliderEl = getVideoPlayerVolSliderEl(videoPlayerEl);

    if (!videoPlayerVolSliderEl) {
      throw new Error('Video player volume slider not found.  Perhaps application is in picture-in-picture mode?');
    }

    const setCurrentVolume = (event) => {
      // Ignore any vol changes for ads
      if (document.querySelector(adTestSel) || adLaunched) {
        return;
      }

      // Always find the video node element as Twitch app may have re-created tracked element
      videoNodeVolCurrent = getVideoNodeEl(videoPlayerEl).volume.toFixed(2);
      log('info', `Volume modified to: '${videoNodeVolCurrent}'.`);
    };

    // Standard volume change listeners
    videoPlayerVolSliderEl.addEventListener('keyup', (event) => {
      if (!event.key) {
        return;
      }

      if (!['ArrowUp', 'ArrowDown'].includes(event.key)) {
        return;
      }
      setCurrentVolume(event);
    });

    videoPlayerVolSliderEl.addEventListener('mouseup', setCurrentVolume);
    videoPlayerVolSliderEl.addEventListener('scroll', (event) => _.debounce(setCurrentVolume, 1000));

    // TODO: FFZ scrollup & scrolldown support
  };

  const retryWrap = function(fnToRetry, args, intervalInMs, maxRetries, actionDescription) {
    const retry = (fn, retries = 3) => fn()
      .catch((e) => {
        if (retries <= 0) {
          log('error', `${actionDescription} - failed after ${maxRetries} retries.`)
          return Promise.reject(e);
        }
        log('warn', `${actionDescription} - retrying another ${retries} time(s).`);
        return retry(fn, --retries)
      });

    const delay = ms => new Promise((resolve) => setTimeout(resolve, ms));
    const delayError = (fn, args, ms) => () => fn(...args).catch((e) => delay(ms).then((y) => Promise.reject(e)));
    return retry(delayError(fnToRetry, args, intervalInMs), maxRetries);
  };

  const spawnFindVideoPlayerEl = async function() {
    const actionDescription = 'Finding video player';
    log('info', `${actionDescription}...`);
    const findVideoPlayerEl = async () => {
      const videoPlayerEl = document.querySelector(videoPlayerSel);
      if (!videoPlayerEl) {
        return Promise.reject('Video player not found.');
      }
      return videoPlayerEl;
    };
    return await retryWrap(findVideoPlayerEl, [], 2000, maxRetriesFindVideoPlayer, actionDescription);
  };

  const spawnVolumeChangeListener = async function(videoPlayerEl) {
    const actionDescription = 'Listening for volume changes';
    log('info', `${actionDescription}...`);
    retryWrap(listenForVolumeChanges, [videoPlayerEl], 2000, maxRetriesVolListener, actionDescription);
  };

  const spawnVideoPlayerAdSkipObservers = async function(videoPlayerEl) {
    const actionDescription = 'Attaching MO';
    log('info', `${actionDescription}...`);
    retryWrap(attachMO, [videoPlayerEl], 2000, maxRetriesVideoPlayerObserver, actionDescription);
  };

  const spawnObservers = async function () {
    try {
      const videoPlayerEl = await spawnFindVideoPlayerEl();

      if (!videoPlayerEl) {
        throw new Error('Could not find video player.');
      }
      log('info', 'Success - video player found.');

      spawnVolumeChangeListener(videoPlayerEl);
      spawnVideoPlayerAdSkipObservers(videoPlayerEl);
    } catch (error) {
      log('error', error);
    }
  }

  log('info', 'Page loaded - attempting to spawn observers...');
  spawnObservers();

  log('info', 'Overloading history push state')
  var pushState = history.pushState;
  history.pushState = function () {
    pushState.apply(history, arguments);

    log('info', 'History change - attempting to spawn observers...')
    spawnObservers();
  };
})();