YouTube Playlist Autoplay Button

Allows the user to toggle autoplaying to the next video once the current video ends. Stores the setting locally.

// ==UserScript==
// @name        YouTube Playlist Autoplay Button
// @description Allows the user to toggle autoplaying to the next video once the current video ends. Stores the setting locally.
// @version     2.0.8
// @license     GNU GPLv3
// @match       https://www.youtube.com/*
// @namespace   https://greasyfork.org/users/701907
// @require     https://cdn.jsdelivr.net/gh/cyfung1031/userscript-supports@8fac46500c5a916e6ed21149f6c25f8d1c56a6a3/library/ytZara.js
// @require     https://cdn.jsdelivr.net/gh/cyfung1031/userscript-supports@7221a4efffd49d852de0074ec503d4febb99f28b/library/nextBrowserTick.min.js
// @run-at      document-start
// @unwrap
// @inject-into page
// @noframes
// ==/UserScript==

/**
 *
 * This is based on the [YouTube Prevent Playlist Autoplay](https://greasyfork.org/en/scripts/415542-youtube-prevent-playlist-autoplay)
 * GNU GPLv3 license, credited to [MegaScientifical](https://greasyfork.org/en/users/701907-megascientifical) (https://www.github.com/MegaScience)
 *
**/

/**
 * This script now is maintained by [CY Fung](https://greasyfork.org/en/users/371179)
 * It uses the technlogy same as Tabview Youtube and YouTube Super Fast Chat to achieve the robust implementation and high performance.
 *
 * This userscript supports Violentmonkey, Tampermonkey, Firemonkey, Stay, MeddleMonkey, etc. EXCEPT GreaseMonkey.
 *
**/

/**

Copyright (c) 2020-2023 MegaScientifical
Copyright (c) 2023 CY Fung

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 3
of the License, or any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see http://www.gnu.org/licenses/.


**/

(async () => {

  const { insp, indr, isYtHidden } = ytZara;
  const Promise = (async () => { })().constructor;

  let debug = false
  const elementCSS = {
    parent: [
      '#playlist-action-menu[autoplay-container="1"] .top-level-buttons', // Playlist parent area in general.
      'ytd-playlist-panel-renderer[playlist-type] #playlist-action-menu[autoplay-container="2"]' // Playlist parent area for Mixes.
    ],
    cssId: 'YouTube-Prevent-Playlist-Autoplay-Style', // ID for the Style element to be injected into the page.
    buttonOn: 'YouTube-Prevent-Playlist-Autoplay-Button-On',
    buttonContainer: 'YouTube-Prevent-Playlist-Autoplay-Button-Container',
    buttonBar: 'YouTube-Prevent-Playlist-Autoplay-Button-Bar',
    buttonCircle: 'YouTube-Prevent-Playlist-Autoplay-Button-Circle'
  }
  const prefix = 'YouTube Prevent Playlist Autoplay:'
  const localStorageProperty = 'YouTubePreventPlaylistAutoplayStatus'
  // Get current autoplay setting from local storage.
  let autoplayStatus = loadAutoplayStatus()
  let transition = false
  let navigateStatus = -1;
  let fCounter = 0;

  // Instead of writing the same log function prefix throughout
  // the code, this function automatically applies the prefix.
  const customLog = (...inputs) => console.log(prefix, ...inputs)

  // Functions to get/set if you have autoplay off or on.
  // This applies to localStorage of the domain, so
  // clearing that will clear the stored value.
  function loadAutoplayStatus() {
    if (debug) customLog('Loading autoplay status.')
    return window.localStorage.getItem(localStorageProperty) === 'true'
  }

  function saveAutoplayStatus() {
    if (debug) customLog('Saving autoplay status.')
    window.localStorage.setItem(localStorageProperty, autoplayStatus)
  }

  // Ancient, common function for adding a style to the page.
  function addStyle(id, css) {
    if (document.getElementById(id) !== null) {
      if (debug) customLog('CSS has already been applied.')
      return
    }
    const head = document.head || document.getElementsByTagName('head')[0]
    if (!head) {
      if (debug) customLog('document.head is missing.')
      return
    }
    const style = document.createElement('style')
    style.id = id
    style.textContent = css
    head.appendChild(style)
  }

  // Sets the ability to autoplay based on the user's current setting,
  // then sets the state of all autoplay toggle switches in the page.
  function setAssociatedAutoplay() {
    const manager = getManager()
    if (!manager) {
      if (debug) customLog('Manager is missing.')
      return
    }
    if (typeof manager.canAutoAdvance_ !== 'boolean') {
      customLog('manager.canAutoAdvance_ is not boolean');
    } else {
      if (navigateStatus !== 1) manager.canAutoAdvance_ = !!autoplayStatus;
    }
    for (const b of document.body.getElementsByClassName(elementCSS.buttonContainer)) {
      b.classList.toggle(elementCSS.buttonOn, autoplayStatus)
      b.setAttribute('title', `Autoplay is ${autoplayStatus ? 'on' : 'off'}`)
    }
  }

  // Toggles the ability to autoplay, then sets the rest
  // and stores the current status of autoplay locally.
  function toggleAutoplay(e) {
    e.stopPropagation()
    if (transition) {
      if (debug) customLog('Button is transitioning.')
      e.preventDefault()
      return
    }
    autoplayStatus = !autoplayStatus
    setAssociatedAutoplay()
    saveAutoplayStatus()
    if (debug) customLog('Autoplay toggled to:', autoplayStatus)
  }

  // Retrieves the current playlist manager to adjust and use.
  function getManager() {
    return insp(document.querySelector('yt-playlist-manager'));
  }

  // Playlists cannot autoplay if the variable "canAutoAdvance_" is set to false.
  // It is messy to toggle back since various functions switch it.
  // Luckily, all attempts to set it to true are done through the same function.
  // By replacing this function, autoplay can be controlled by the user.
  function interceptManagerForAutoplay() {
    const manager = getManager()
    if (!manager) {
      if (debug) customLog('Manager is missing.')
      return
    }
    if (manager.interceptedForAutoplay) return
    manager.interceptedForAutoplay = true
    addStyle(elementCSS.cssId, elementCSS.styleText)
    if (debug) customLog('Autoplay is now controlled.')
  }

  const transitionOn = () => {
    transition = true;
    // container.style.pointerEvents = 'none';
  }
  const transitionOff = () => {
    transition = false;
    // container.style.pointerEvents = '';
  }

  const moButtonAttachment = new MutationObserver((entries) => {
    for (const entry of entries) {
      const { target, previousSibling, removedNodes } = entry;
      if (removedNodes.length >= 1 && target.isConnected === true && previousSibling && previousSibling.isConnected === true) {
        for (const elem of removedNodes) {
          if (elem.classList.contains(`${elementCSS.buttonContainer}`) && elem.isConnected === false) {
            target.insertBefore(elem, previousSibling.nextSibling);
          }
        }
      }
    }
  })

  function appendButtonContainer(domElement) {
    if (!domElement || !(domElement instanceof Element) || !elementCSS.buttonContainer || domElement.querySelector(`.${elementCSS.buttonContainer}`)) return;
    const container = document.createElement('div')
    container.classList.add(elementCSS.buttonContainer)
    container.classList.toggle(elementCSS.buttonOn, autoplayStatus)
    container.setAttribute('title', `Autoplay is ${autoplayStatus ? 'on' : 'off'}`)
    container.addEventListener('click', toggleAutoplay, false)
    // if (debug && e) container.event = [...e]

    const bar = document.createElement('div')
    bar.classList.add(elementCSS.buttonBar)
    container.appendChild(bar)

    const circle = document.createElement('div')
    circle.classList.add(elementCSS.buttonCircle)
    // Use the transition as the cooldown.
    circle.addEventListener('transitionrun', transitionOn, { passive: true, capture: false });
    circle.addEventListener('transitionend', transitionOff, { passive: true, capture: false });
    circle.addEventListener('transitioncancel', transitionOff, { passive: true, capture: false });
    container.appendChild(circle)

    domElement.appendChild(container)
    if (debug) customLog('Button added.')

    moButtonAttachment.observe(domElement, { childList: true, subtree: false }); // re-adding after removal

  }

  function appendButtonContainerToMenu(menu) {
    if (!menu || !(menu instanceof Element)) return;
    const headers = menu.querySelectorAll('.top-level-buttons:not([hidden])')
    if (headers.length >= 1) {
      for (const header of headers) {
        // add button to each matched header, ignore those have been proceeded without re-rendering.
        appendButtonContainer(header);
      }
      menu.setAttribute('autoplay-container', '1');
    } else {
      // add button to the menu if no header is found, ignore those have been proceeded without re-rendering.
      appendButtonContainer(menu);
      menu.setAttribute('autoplay-container', '2');
    }
  }

  const ytReady = new Promise(_resolve => {
    document.addEventListener('yt-action', async function () {
      const resolve = _resolve;
      _resolve = null;
      if (!resolve) return;
      await customElements.whenDefined('yt-playlist-manager').then();
      await new Promise(resolve => setTimeout(resolve, 100));
      resolve();
    }, { once: true, passive: true, capture: true });
  })

  async function setupMenu(menu) {
    if (!(menu instanceof Element)) return;
    await ytReady.then();

    // YouTube can have multiple variations of the playlist UI hidden in the page.
    // For instance, the sidebar and corner playlists. They also misuse IDs,
    // whereas they can appear multiple times in the same page.
    // This isolates one potentially visible instance.
    if (isYtHidden(menu)) {
      // the menu is invalid
      menu.removeAttribute('autoplay-container');
    } else {
      interceptManagerForAutoplay()
      appendButtonContainerToMenu(menu);
      setAssociatedAutoplay() // set canAutoAdvance_ when the page is loaded.
    }
  }

  function onNavigateStart() { // navigation endpoint is clicked
    // canAutoAdvance_ will become false in onYtNavigateStart_
    navigateStatus = 1;
    if (fCounter > 1e9) fCounter = 9;
    fCounter++;
  }

  function onNavigateCache() {
    navigateStatus = 1;
    if (fCounter > 1e9) fCounter = 9;
    fCounter++;
  }

  function onNavigateFinish() {
    // canAutoAdvance_ will become true in onYtNavigateFinish_
    navigateStatus = 2;
    if (fCounter > 1e9) fCounter = 9;
    fCounter++;
    const t = fCounter;
    interceptManagerForAutoplay()
    setTimeout(() => {
      if (t !== fCounter) return;
      if (navigateStatus === 2) {
        // canAutoAdvance_ has become true in onYtNavigateFinish_
        setAssociatedAutoplay();  // set canAutoAdvance_ to true or false as per preferred setting
      }
    }, 100);
  }

  const attrMo = new MutationObserver((entries) => {
    // the state of DOM is being changed, expand/collaspe state, rendering after dataChanged, etc.
    let m = new Set();
    for (const entry of entries) {
      m.add(entry.target); // avoid proceeding the same element target
    }
    m.forEach((target) => {
      if (target && target.isConnected === true) { // ensure the DOM is valid and attached to the document
        setupMenu(indr(target)['playlist-action-menu']); // add the button to the menu, if applicable
      }
    });
    m.clear();
    m = null;
  });

  // listen events on the script execution in document-start
  document.addEventListener('yt-navigate-start', onNavigateStart, false);
  document.addEventListener('yt-navigate-cache', onNavigateCache, false);
  document.addEventListener('yt-navigate-finish', onNavigateFinish, false);


  elementCSS.styleText = `
        ${elementCSS.parent.join(', ')} {
            align-items: center;
        }
        .${elementCSS.buttonContainer} {
            position: relative;
            height: 20px;
            width: 36px;
            cursor: pointer;
            margin-left: 8px;
        }
        .${elementCSS.buttonContainer} .${elementCSS.buttonBar} {
            position: absolute;
            top: calc(50% - 7px);
            height: 14px;
            width: 36px;
            background-color: var(--paper-toggle-button-unchecked-bar-color, #000000);
            border-radius: 8px;
            opacity: 0.4;
        }
        .${elementCSS.buttonContainer} .${elementCSS.buttonCircle} {
            position: absolute;
            left: 0;
            height: 20px;
            width: 20px;
            background-color: var(--paper-toggle-button-unchecked-button-color, var(--paper-grey-50));
            border-radius: 50%;
            box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.6);
            transition: left linear .08s, background-color linear .08s;
        }
        .${elementCSS.buttonContainer}.${elementCSS.buttonOn} .${elementCSS.buttonCircle} {
            position: absolute;
            left: calc(100% - 20px);
            background-color: var(--paper-toggle-button-checked-button-color, var(--primary-color));
        }
      `;

  if (!document.documentElement) await ytZara.docInitializedAsync(); // wait for document.documentElement is provided

  await ytZara.promiseRegistryReady(); // wait for YouTube's customElement Registry is provided (old browser only)

  const cProto = await ytZara.ytProtoAsync('ytd-playlist-panel-renderer'); // wait for customElement registration

  if (cProto.attached145 || cProto.setupPlaylistActionMenu145) {
    console.warn('YouTube Playlist Autoplay Button cannot inject JS code to ytd-playlist-panel-renderer');
    return;
  }

  cProto.attached145 = cProto.attached;
  cProto.setupPlaylistActionMenu145 = function () {
    nextBrowserTick(() => { // avoid blocking the DOM tree rendering
      const hostElement = this.hostElement;
      if (!hostElement || hostElement.isConnected !== true) return;
      attrMo.observe(hostElement, {
        attributes: true,
        attributeFilter: [
          'has-playlist-buttons', 'has-toolbar', 'hidden', 'playlist-type', 'within-miniplayer', 'hide-header-text'
        ]
      });
      setupMenu(indr(this)['playlist-action-menu']); // add the button to the menu which is just attached to Dom Tree, if applicable
    });
  }
  cProto.attached = function () {
    try {
      this.setupPlaylistActionMenu145();
    } finally {
      const f = this.attached145;
      return f ? f.apply(this, arguments) : void 0;
    }
  }

  if (debug) customLog('Initialized.')


})();