- // ==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://gf.qytechs.cn/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://gf.qytechs.cn/en/scripts/415542-youtube-prevent-playlist-autoplay)
- * GNU GPLv3 license, credited to [MegaScientifical](https://gf.qytechs.cn/en/users/701907-megascientifical) (https://www.github.com/MegaScience)
- *
- **/
-
- /**
- * This script now is maintained by [CY Fung](https://gf.qytechs.cn/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.')
-
-
- })();