YouTube Prevent Playlist Autoplay

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

// ==UserScript==
// @name        YouTube Prevent Playlist Autoplay
// @description Allows the user to toggle autoplaying to the next video once the current video ends. Stores the setting locally.
// @license     GNU GPLv3
// @match       https://www.youtube.com/*
// @version     1.5.1
// @namespace   https://greasyfork.org/users/701907
// @run-at      document-start
// @inject-into page
// @noframes
// ==/UserScript==

/*
  * Credit to [CY Fung](https://greasyfork.org/en/users/371179) for fixes December 2023.
  * They will be maintaining this script moving forward [here](https://greasyfork.org/en/scripts/481929-youtube-playlist-autoplay-button).
*/

const primary = function () {
    let debug = false
    const elementCSS = {
        parent: [
            '#playlist-action-menu .top-level-buttons', // Playlist parent area in general.
            'ytd-playlist-panel-renderer:is([playlist-type="RDAM"], [playlist-type="RDEM"]) #playlist-action-menu' // Playlist parent area for Mixes.
        ],
        style: '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',
        buttonRegions: [
            'ytd-watch-flexy.ytd-page-manager:not([hidden]) ytd-playlist-panel-renderer:not([collapsed]) #playlist-action-menu .top-level-buttons:not([hidden])', // Playlist adjacent to/below the video.
            'ytd-miniplayer #playlist-action-menu .top-level-buttons:not([hidden])', // Playlist in the mini-player when viewing other pages.
            'ytd-watch-flexy.ytd-page-manager:not([hidden]) ytd-playlist-panel-renderer:is([playlist-type="RDAM"], [playlist-type="RDEM"]):not([collapsed]) #playlist-action-menu' // Playlist in Mixes.
        ].join(', ')
    }
    const excludedPagesRegex = new RegExp('^https?:\/\/(www.)?youtube\.com\/(embed|about|trends|kids|jobs|ads|yt|creators|creatorresearch|creators-for-change|nextup|space|csai-match|supported_browsers|howyoutubeworks|(t|intl)\/[^\/]+)\/.*$')
    const prefix = 'YouTube Prevent Playlist Autoplay:'
    const localStorageProperty = 'YouTubePreventPlaylistAutoplayStatus'
    // Get current autoplay setting from local storage.
    let autoplayStatus = loadAutoplayStatus()
    // Because "canAutoAdvance_" has a functional purpose,
    // track the expected state for safety sake.
    let currentExpected = true
    let transition = false

    // 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
        }
        manager.canAutoAdvance_ = !autoplayStatus ? false : currentExpected
        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.')
            return
        }
        autoplayStatus = !autoplayStatus
        setAssociatedAutoplay()
        saveAutoplayStatus()
        if (debug) customLog('Autoplay toggled to:', autoplayStatus)
    }

    // Retrieves the current playlist manager to adjust and use.
    function getManager() {
        const [managerElement] = document.getElementsByTagName('yt-playlist-manager')
        return managerElement?.polymerController || managerElement?.inst || (!managerElement ? null : managerElement)
    }

    async function onPageLoaded(...e) {
        if (excludedPagesRegex.test(window.location.href)) return
        main() // Fallback call in case it was not called appropriately.
        await new Promise(resolve => setTimeout(resolve, 300))
        // 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.
        const headers = document.body.querySelectorAll(elementCSS.buttonRegions)
        for (const header of headers) {
            if (header == null) {
                if (debug) customLog('Header is missing.')
                continue
            }
            if (debug) customLog('Removing old buttons.')
            for (const oldButton of header.getElementsByClassName(elementCSS.buttonContainer)) {
                if (debug) customLog('Button with the event of ', oldButton.event, ' removed.')
                oldButton.remove()
            }
            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)
            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', () => transition = true)
            circle.addEventListener('transitionend', () => transition = false)
            circle.addEventListener('transitioncancel', () => transition = false)
            container.appendChild(circle)

            header.appendChild(container)
            if (debug) customLog('Button added.')
        }
        setAssociatedAutoplay() // set canAutoAdvance_ when the page is loaded.
    }

    // 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 main() {
        const manager = getManager()
        if (!manager) {
            if (debug) customLog('Manager is missing.')
            return
        }
        if (manager.interceptedForAutoplay) return
        addStyle(elementCSS.style, elementCSS.styleSheet)
        manager.interceptedForAutoplay = true
        manager.onYtNavigateStart_ = function () { this.canAutoAdvance_ = currentExpected = false }
        manager.onYtNavigateFinish_ = function () { currentExpected = true; this.canAutoAdvance_ = autoplayStatus ? currentExpected : false }
        customLog('Autoplay is now controlled.')
    }

    // Adds the CSS to style the autoplay toggle button.
    function init() {
        elementCSS.styleSheet = `${elementCSS.parent.join(', ')} {
            align-items: center;
        }

        ${elementCSS.parent.map(p => `${p} .${elementCSS.buttonContainer}`).join(', ')} {
            position: relative;
            height: 20px;
            width: 36px;
            cursor: pointer;
            margin-left: 8px;
        }

        ${elementCSS.parent.map(p => `${p} .${elementCSS.buttonContainer} .${elementCSS.buttonBar}`).join(', ')} {
            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.parent.map(p => `${p} .${elementCSS.buttonContainer} .${elementCSS.buttonCircle}`).join(', ')} {
            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.parent.map(p => `${p} .${elementCSS.buttonContainer}.${elementCSS.buttonOn} .${elementCSS.buttonCircle}`).join(', ')} {
            position: absolute;
            left: calc(100% - 20px);
            background-color: var(--paper-toggle-button-checked-button-color, var(--primary-color));
        }`
        customLog('Initialized.')
    }

    init()

    // Initializes autoplay control.
    window.addEventListener('yt-playlist-data-updated', main, { once: true })
    //window.addEventListener('yt-player-updated', main, { once: true })
    // Each page change, checks if it needs to be (re)applied.
    window.addEventListener('yt-page-type-changed', main)
    // Adds the autoplay switches to the page where appropriate.
    //window.addEventListener('yt-visibility-refresh', onPageLoaded) // No longer works.
    //window.addEventListener('yt-playlist-data-updated', onPageLoaded) // Works a lot of the time, but has cases where it won't.
    window.addEventListener('yt-navigate-finish', onPageLoaded) // Lets just check if it's needed each time this happens...
}

let mObserverInjectScript = new MutationObserver(() => {
    const root = (document.body || document.head || document.documentElement)
    if (root) {
        mObserverInjectScript.disconnect()
        mObserverInjectScript.takeRecords()
        mObserverInjectScript = null
        const script = document.createElement('script')
        script.textContent = `(${primary})()`
        root.appendChild(script)
    }
})
mObserverInjectScript.observe(document, { childList: true, subtree: true })