YouTube Playlist Autoplay Button

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.9
// @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@c2b707e4977f77792042d4a5015fb188aae4772e/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.')


})();