Raw Source
RobertWesner / YouTube Play All

// ==UserScript==
// @name            YouTube Play All
// @description     Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel
// @version         20260222-0
// @author          Robert Wesner (https://robert.wesner.io)
// @license         MIT
// @namespace       http://robert.wesner.io/
// @match           https://*.youtube.com/*
// @icon            https://scripts.yt/favicon.ico
// @connect         ytplaylist.robert.wesner.io
// @downloadURL     https://raw.githubusercontent.com/RobertWesner/YouTube-Play-All/main/script.user.js
// @updateURL       https://raw.githubusercontent.com/RobertWesner/YouTube-Play-All/main/script.user.js
// @homepageURL     https://scripts.yt/scripts/ytpa-youtube-play-all-YTPA-Play-All-YouTube-Videos-Of-A-Channel
// @supportURL      https://github.com/RobertWesner/YouTube-Play-All/issues
// @grant           GM.xmlHttpRequest
// @grant           GM.getValue
// @grant           GM.setValue
// @grant           GM.deleteValue
// @grant           GM.listValues
// ==/UserScript==

// ### SAFETY ###
//
// Using this script will in almost all cases NOT lead to your accounts being suspended
// as it only refers to internal existing YouTube playlists
// and does minor UI/UX changes entirely on the client side.
//
// Keep in mind, this software is provided as is
// and without any guarantees or liability by the author and contributors.
// Refer to the license for more details.
//
// ### PRIVACY ###
//
// 99% of this script is running on your device without any calls to outside of YouTube.
//
// GM.xmlHttpRequest() is only used to retrieve the fallback playlist emulation data in absolute edge cases
// when the playlist exceeds thousands, rather tens of thousands, of items.
//
// The API is open source and hosted by me personally.
//
// GDPR privacy information: https://datenschutz.robertwesner.de/dataprotection
// Source of the API: https://github.com/RobertWesner/youtube-playlist

// TODO: REALLY have to test all of this on mobile, been a while

(G => (async function __ytpa_root_call__(loadModules, loadStyles) {
    'use strict';

    // --- setup ---
    
    if (typeof _environment_ === 'undefined' || !_environment_) {
        window._environment_ = 'userscript';
    }

    const verifyModule = ([name, mod]) => {
        if (typeof mod === 'function') {
            // we cant gave compile time errors... because we don't compile
            console.error(`Tell your local dev he "forgot to call the module constructor for ${name}".\nThis script is probably broken now.`);
        }
    };

    /**
     * @template {string} N
     * @template {object} M
     *
     * @param {N} name
     * @param {M} modules
     * @return {M[N]}
     */
    const asyncModule = async (name, modules) => {
        const module = modules[name];
        verifyModule([name, module]);

        return module;
    };

    const [syncModules, asyncModules] = loadModules();
    Object.entries(syncModules).forEach(verifyModule);
    const {
        ControlFlow: { _ },
        Fmt,
        HtmlCreation: { $builder, $style },
        Console: console,
        Safety: { handleError, attachSafetyListener, safeTimeout, safeInterval, safeEventListener },
        Versioned,
        Greeter,
        Debug,
    } = syncModules;
    const SettingsStorage = await asyncModule('SettingsStorage', asyncModules);
    const SettingsDialog = await asyncModule('SettingsDialog', asyncModules);

    attachSafetyListener();

    const debugInfo = {
        time: new Date().toISOString(),
        version: GM.info.script.version,
        userAgent: navigator.userAgentData || navigator.userAgent,
        language: navigator.language,
    };
    Greeter.greet(debugInfo);
    document.head.append(
        $builder('script#ytpa-debug-info[type="application/json"]')
            .onBuildText(JSON.stringify(debugInfo, null, 2))
            .build(),
    );

    const scriptVersion = GM.info.script.version || null;
    if (scriptVersion && /-(alpha|beta|dev|test)$/.test(scriptVersion)) {
        console.info(`Running debug build version ${GM.info.script.version}, watch out for bugs!`);
    }

    loadStyles().forEach(([id, css]) => $style(id, css));

    if (_environment_ === 'userscript') {
        unsafeWindow.YTPA_tools = Debug.YTPA_tools;
    } else if (_environment_ === 'extension') {
        globalThis.__YTPA_CONSOLE_API__ = Debug.YTPA_tools;
    }

    // --- actual code ---

    const getVideoId = url => new URLSearchParams(new URL(url).search).get('v');

    /**
     * @return {{ getProgressState: () => { current: number, duration: number }, pauseVideo: () => void, seekTo: (number) => void }}
     */
    const getPlayer = () => document.querySelector('#movie_player');

    const isAdPlaying = () => !!document.querySelector('.ad-interrupting');

    const redirect = (v, list, ytpaRandom = null) => {
        if (location.host === 'm.youtube.com') {
            // TODO: Client side routing on mobile? some day...
        } else {
            const redirector = document.createElement('a');
            redirector.className = 'yt-simple-endpoint style-scope ytd-playlist-panel-video-renderer';
            redirector.setAttribute('hidden', '');
            redirector.data = {
                'commandMetadata': {
                    'webCommandMetadata': {
                        'url': `/watch?v=${v}&list=${list}${ytpaRandom !== null ? `&ytpa-random=${ytpaRandom}` : ''}`,
                        'webPageType': 'WEB_PAGE_TYPE_WATCH',
                        'rootVe': 3832, // ??? required though
                    },
                },
                'watchEndpoint': {
                    'videoId': v,
                    'playlistId': list,
                },
            };
            document.querySelector('ytd-playlist-panel-renderer #items').append(redirector);
            redirector.click();
        }
    };

    let id = '';

    // This looks funny, but is currently (2025) the
    // most reliable way to fetch the channelId from within the browser context
    const refreshId = async () => {
        let channelId = '';

        const pass = () => /UC[\w-]+/.test(channelId);

        const tryFetch = async () => {
            try {
                const html = await (await fetch(document.querySelector('#content ytd-rich-item-renderer a')?.href)).text();
                channelId = /var ytInitialData.+?["']channelId["']:["'](UC[\w-]+)["']/.exec(html)?.[1] ?? '';
            } finally {
                // pass
            }
        };

        // try it from the first video/short/stream
        await tryFetch();

        // wait for a bit and try again
        if (!pass()) {
            await new Promise(resolve => {
                safeTimeout(() => {
                    (async () => {
                        await tryFetch();
                        resolve();
                    })();
                }, 1000);
            });
        }

        // unreliable in some cases but better than not trying,
        // getting it from the channel view
        if (!pass()) {
            try {
                const html = await (await fetch(location.href)).text();
                const i = html.indexOf('<link rel="canonical" href="https://www.youtube.com/channel/UC') + 60;
                channelId = html.substring(i, i + 24);
            } finally {
                // pass
            }
        }

        if (!pass()) {
            handleError('Could not determine channelId...');

            return;
        }

        id = channelId.substring(2);
    };

    // 20260802-0 Fixes new YouTube UI not keeping the selected state
    let currentSelection = null;

    const apply = () => {
        const container = document.querySelector('ytm-feed-filter-chip-bar-renderer, ytd-feed-filter-chip-bar-renderer, chip-bar-view-model.ytChipBarViewModelHost');
        let height = 32;
        if (container !== null) {
            const computedStyle = getComputedStyle(container);
            height = container.offsetHeight - parseFloat(computedStyle.paddingTop);
        }
        document.querySelector('#ytpa-btn-height').textContent = `body { --ytpa-btn-height: ${height}px; }`;

        if (id === '') {
            // do not apply prematurely, caused by mutation observer
            return;
        }

        let parent = location.host === 'm.youtube.com'
            // mobile view
            ? document.querySelector('ytm-feed-filter-chip-bar-renderer .chip-bar-contents, ytm-feed-filter-chip-bar-renderer > div')
            // desktop view
            : document.querySelector('ytd-feed-filter-chip-bar-renderer iron-selector#chips, chip-bar-view-model.ytChipBarViewModelHost');

        // 202602 New UI
        if (parent?.tagName?.toLowerCase() === 'chip-bar-view-model') {
            if (currentSelection === null) {
                currentSelection = 1;
            }

            // 20260220-0 See #56
            Versioned.v20260220.getTypeButtons().then(
                elements => elements.forEach((btn, i) => btn.addEventListener('click', () => currentSelection = i + 1)),
            );

            // TODO: refine this into handling "members only"/"popular" for those specific playlists! See documentation
        }

        // #5: add a custom container for buttons if Latest/Popular/Oldest is missing
        if (parent === null) {
            const grid = document.querySelector('ytd-rich-grid-renderer, ytm-rich-grid-renderer, div.ytChipBarViewModelChipWrapper');
            grid.insertAdjacentElement('afterbegin', $builder('div').className('ytpa-button-container').build());
            parent = grid.querySelector('.ytpa-button-container');
        } else if (!document.querySelector('.ytpa-btn-spacer')) {
            parent.insertAdjacentElement(
                'beforeend',
                $builder('span.ytpa-btn-spacer').build(),
            );
        }

        // See: available-lists.md
        const [allPlaylist, popularPlaylist] = window.location.pathname.endsWith('/videos')
            // Normal videos
            // list=UULP has the all videos sorted by popular
            // list=UU<ID> adds shorts into the playlist, list=UULF<ID> has videos without shorts
            ? ['UULF', 'UULP']
            // Shorts
            : window.location.pathname.endsWith('/shorts')
                ? ['UUSH', 'UUPS']
                // Live streams
                : ['UULV', 'UUPV'];

        // Check if popular videos are displayed
        if (currentSelection === 2 || parent.querySelector(':nth-child(2).selected, :nth-child(2).iron-selected')) {
            parent.insertAdjacentElement(
                'beforeend',
                $builder(`a.ytpa-btn.ytpa-play-all-btn[role="button"]`)
                    .href(`/playlist?list=${popularPlaylist}${id}&playnext=1`)
                    .onBuildText('Play Popular')
                    .build(),
            );
        } else if (currentSelection === 1 || parent.querySelector(':nth-child(1).selected, :nth-child(1).iron-selected') || parent.classList.contains('ytpa-button-container')) {
            parent.insertAdjacentElement(
                'beforeend',
                $builder('a.ytpa-btn.ytpa-play-all-btn[role="button"]')
                    .href(`/playlist?list=${allPlaylist}${id}&playnext=1`)
                    .onBuildText('Play All')
                    .build(),
            );
        } else {
            parent.insertAdjacentElement(
                'beforeend',
                $builder('a.ytpa-btn.ytpa-play-all-btn.ytpa-unsupported[role="button"][target="_blank"][rel="noreferrer"]')
                    .href(`https://github.com/RobertWesner/YouTube-Play-All/issues/39`)
                    .onBuildText('No Playlist Found')
                    .build(),
            );
        }

        if (location.host === 'm.youtube.com') {
            // YouTube returns an "invalid response" when using client side routing for playnext=1 on mobile
            document.querySelectorAll('.ytpa-btn').forEach(btn => safeEventListener(btn, 'click', event => {
                event.preventDefault();

                window.location.href = btn.href;
            }));
        } else {
            // Only allow random play in desktop version for now
            parent.insertAdjacentElement(
                'beforeend',
                $builder('span.ytpa-btn.ytpa-random-btn.ytpa-btn-sections')
                    .onBuildAppend(
                        $builder('a.ytpa-btn-section[role="button"]')
                            .href(`/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=random&ytpa-random-initial=1`)
                            .onBuildText('Play Random')
                            .build(),
                        $builder('span')
                            .className('ytpa-btn-section ytpa-random-more-options-btn ytpa-hover-popover')
                            .role('button')
                            .tabindex('0')
                            .aria_label('More options for random play')
                            .aria_haspopup('menu')
                            .aria_expanded('false')
                            .onBuildText('▾')
                            .build(),
                        $builder('span.ytpa-random-btn-tab-fix')
                            .tabindex('-1')
                            .aria_hidden('true')
                            .onBuildText('▾')
                            .build(),
                    )
                    .build(),
            );

            document.body.insertAdjacentElement(
                'afterbegin',
                $builder('div')
                    .className('ytpa-random-popover')
                    .role('menu')
                    .aria_label('Random play options')
                    .hidden('')
                    .onBuildAppend(
                        $builder('a[role="menuitem"]')
                            .href(`/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-newest`)
                            .aria_label('Play Random prefer newest')
                            .onBuildText('Prefer newest')
                            .build(),
                        $builder('a[role="menuitem"]')
                            .href(`/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-oldest&ytpa-random-initial=1`)
                            .aria_label('Play Random prefer oldest')
                            .onBuildText('Prefer oldest')
                            .build(),
                    )
                    .build(),
            );

            const randomMoreOptionsBtn = document.querySelector('.ytpa-random-more-options-btn');
            const randomPopover = document.querySelector('.ytpa-random-popover');

            const showPopover = () => {
                const rect = randomMoreOptionsBtn.getBoundingClientRect();
                randomPopover.style.top = rect.bottom.toString() + 'px';
                randomPopover.style.left = rect.right.toString() + 'px';
                randomPopover.removeAttribute('hidden');
                randomPopover.querySelector('a').focus();
                randomMoreOptionsBtn.setAttribute('aria-expanded', 'true');
            };
            const hidePopover = () => {
                randomPopover.setAttribute('hidden', '');
                randomMoreOptionsBtn.setAttribute('aria-expanded', 'false');
                document.querySelector('.ytpa-random-btn-tab-fix').focus();
            };

            safeEventListener(randomMoreOptionsBtn, 'click', showPopover);
            safeEventListener(randomMoreOptionsBtn, 'keydown', event => {
                if (event.key === 'Enter' || event.key === ' ') {
                    event.preventDefault();
                    showPopover();
                }
            });
            safeEventListener(randomPopover, 'mouseleave', hidePopover);
            safeEventListener(randomPopover.querySelector('a:last-of-type'), 'focusout', hidePopover);

            // settings currently also only on desktop
            parent.insertAdjacentElement(
                'beforeend',
                $builder('span.ytpa-btn.ytpa-settings-btn[role="button"]')
                    .onBuildAppend(
                        document.importNode(new DOMParser().parseFromString(`
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" aria-label="open settings">
                                <path d="M12 3.2 l1.2.3.6 1.7 1.6.7 1.6-.8 1.3 1.3-.8 1.6.7 1.6 1.7.6.3 1.2 -.3 1.2-1.7.6 -.7 1.6.8 1.6 -1.3 1.3-1.6-.8 -1.6.7-.6 1.7 -1.2.3-1.2-.3 -.6-1.7-1.6-.7 -1.6.8-1.3-1.3 .8-1.6-.7-1.6 -1.7-.6-.3-1.2 .3-1.2 1.7-.6 .7-1.6-.8-1.6 1.3-1.3 1.6.8 1.6-.7.6-1.7 z"></path>
                                <circle cx="12" cy="11.5" r="3"></circle>
                            </svg>
                        `, 'image/svg+xml').documentElement, true),
                    )
                    .on('click', () => SettingsDialog.show())
                    .build(),
            );
        }
    };

    const observer = new MutationObserver(() => {
        // [20250929-0] removeButton first and then apply, not addButton, since we don't need the pathname validation, and we want mobile to also use it
        removeButton();
        apply();
    });

    const addButton = async () => {
        observer.disconnect();

        if (!(window.location.pathname.endsWith('/videos') || window.location.pathname.endsWith('/shorts') || window.location.pathname.endsWith('/streams'))) {
            return;
        }

        // This needs to be this early in the process as otherwise it may use old ids from other channels
        await refreshId();

        currentSelection = null;

        // Regenerate button if switched between Latest and Popular
        if (location.host === 'm.youtube.com') {
            // Mobile needs custom click listeners as mutation observers proved to be unreliable in that UI.
            Array.from(document.querySelectorAll('ytm-feed-filter-chip-bar-renderer ytm-chip-cloud-chip-renderer'))
                .filter(element => !element.hasAttribute('data-ytpa-click-listener-attached'))
                .forEach(
                    element => {
                        element.setAttribute('data-ytpa-click-listener-attached', '');
                        element.addEventListener('click', () => {
                            removeButton();
                            apply();
                        });
                    },
                );
        } else {
            const element = document.querySelector('ytd-browse:not([hidden]) ytd-rich-grid-renderer');
            if (element) {
                observer.observe(element, {
                    attributes: true,
                    childList: false,
                    subtree: false,
                });
            }
        }

        // This check is necessary for the mobile Interval
        if (document.querySelector('.ytpa-play-all-btn')) {
            return;
        }

        // Initially generate button
        apply();
    };

    // Removing the button prevents it from still existing when switching between "Videos", "Shorts", and "Live"
    // This is necessary due to the mobile Interval requiring a check for an already existing button
    const removeButton = () => document.querySelectorAll('.ytpa-btn').forEach(element => element.remove());

    if (location.host === 'm.youtube.com') {
        // The "yt-navigate-finish" event does not fire on mobile
        // Unfortunately pushState is triggered before the navigation occurs, so a Proxy is useless
        safeInterval(addButton, 1000);
    } else {
        safeEventListener(window, 'yt-navigate-start', removeButton);
        safeEventListener(window, 'yt-navigate-finish', addButton);
    }

    // Fallback playlist emulation
    (() => {
        const getItems = playlist => {
            return new Promise(resolve => {
                // Request is only used to fetch the full playlist contents from the YouTube Data API.
                // See comment at the start of the script.
                GM.xmlHttpRequest({
                    method: 'POST',
                    url: 'https://ytplaylist.robert.wesner.io/api/list',
                    data: JSON.stringify({
                        uri: `https://www.youtube.com/playlist?list=${playlist}`,
                        requestType: `YTPA ${GM.info.script.version}`,
                    }),
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    onload: response => {
                        resolve(JSON.parse(response.responseText));
                    },
                    onerror: error => {
                        document.querySelector('.ytpa-playlist-emulator').setAttribute('data-failed', 'rejected');
                    },
                });
            });
        };

        const processItems = items => {
            const itemsContainer = document.querySelector('.ytpa-playlist-emulator .items');
            const params = new URLSearchParams(window.location.search);
            const list = params.get('list');

            items.forEach(
                /**
                 * @param {{
                 *  position: number,
                 *  title: string,
                 *  videoId: string,
                 * }} item
                 */
                item => {
                    const element = document.createElement('div');
                    element.className = 'item';
                    element.textContent = item.title;
                    element.setAttribute('data-id', item.videoId);
                    safeEventListener(element, 'click', () => redirect(item.videoId, list));

                    itemsContainer.append(element);
                },
            );

            markCurrentItem(params.get('v'));
        };

        const playNextEmulationItem = () => {
            // prevent the bug that occurs when clicking on the channel from playlist emulation
            // and then navigating to videos whilst mini-player is still open
            if (window.location.pathname !== '/watch') {
                return;
            }

            document.querySelector(`.ytpa-playlist-emulator .items .item[data-current] + .item`)?.click();
        };

        const markCurrentItem = videoId => {
            const existing = document.querySelector(`.ytpa-playlist-emulator .items .item[data-current]`);
            if (existing) {
                existing.removeAttribute('data-current');
            }

            const current = document.querySelector(`.ytpa-playlist-emulator .items .item[data-id="${videoId}"]`);
            if (current) {
                current.setAttribute('data-current', '');
                current.parentElement.scrollTop = current.offsetTop - 12 * parseFloat(getComputedStyle(document.documentElement).fontSize);
            }
        };

        const emulatePlaylist = () => {
            if (!window.location.pathname.endsWith('/watch')) {
                return;
            }

            const params = new URLSearchParams(window.location.search);
            if (!params.has('list') || params.has('ytpa-random')) {
                return;
            }

            const list = params.get('list');

            // prevent playlist emulation on queue
            // its impossible to fetch that playlist externally anyway
            // https://github.com/RobertWesner/YouTube-Play-All/issues/33
            if (list.startsWith('TLPQ')) {
                return;
            }

            // No user ID in the list, cannot be fetched externally -> no emulation
            if (list.length <= 4) {
                return;
            }

            const existingEmulator = document.querySelector('.ytpa-playlist-emulator');
            if (existingEmulator) {
                if (list === existingEmulator.getAttribute('data-list')) {
                    markCurrentItem(params.get('v'));

                    return;
                } else {
                    // necessary to lose all the client side manipulations like SHIFT + N and the play next button
                    window.location.reload(true);
                }
            }

            if (!(new URLSearchParams(window.location.search).has('list'))) {
                return;
            }

            if (!document.querySelector('#secondary-inner > ytd-playlist-panel-renderer#playlist #items:empty')) {
                return;
            }

            document.querySelector('#secondary-inner > ytd-playlist-panel-renderer#playlist')
                .insertAdjacentElement(
                    'afterend',
                    $builder('div')
                        .className('ytpa-playlist-emulator')
                        .data_list(list)
                        .onBuildAppend(
                            $builder('div')
                                .className('title')
                                .onBuildText('Playlist emulator')
                                .build(),
                            $builder('div.information').onBuildText(Fmt.trimIndent(`
                                It looks like YouTube is unable to handle this large playlist.
                                Playlist emulation is a limited fallback feature of YTPA to enable you to watch even more content.
                            `)).build(),
                            $builder('div.items').build(),
                            $builder('footer.footer').build(),
                        )
                        .build(),
                );

            getItems(list).then(response => {
                if (response.status === 'running') {
                    safeTimeout(() => getItems(list).then(response => processItems(response.items)), 5000);

                    return;
                }

                processItems(response.items);
            });

            const nextButtonInterval = safeInterval(() => {
                const nextButton = document.querySelector('#ytd-player .ytp-next-button.ytp-button:not([ytpa-emulation="applied"])');
                if (nextButton) {
                    clearInterval(nextButtonInterval);

                    // Replace with span to prevent anchor click events
                    const newButton = nextButton.cloneNode(true);
                    newButton.href = 'javascript:void(0)';
                    nextButton.replaceWith(newButton);

                    newButton.setAttribute('ytpa-emulation', 'applied');
                    safeEventListener(newButton, 'click', () => playNextEmulationItem());
                }
            }, 1000);

            // TODO: this does not look like it is called on the new UI,
            //       the new UI seems to preserves the GET-parameter on its own.
            safeEventListener(document.body, 'keydown', event => {
                // SHIFT + N
                if (event.shiftKey && event.key.toLowerCase() === 'n') {
                    event.stopImmediatePropagation();
                    event.preventDefault();

                    playNextEmulationItem();
                }
            }, true);

            safeInterval(() => {
                const player = getPlayer();
                const progressState = player.getProgressState();

                // Do not listen for watch progress when watching advertisements
                if (!isAdPlaying()) {
                    // Autoplay random video
                    if (progressState.current >= progressState.duration - 2) {
                        // make sure vanilla autoplay doesnt take over
                        player.pauseVideo();
                        player.seekTo(0);
                        playNextEmulationItem();
                    }
                }
            }, 500);
        };

        if (location.host === 'm.youtube.com') {
            // TODO: mobile playlist emulation
        } else {
            safeEventListener(window, 'yt-navigate-finish', () => safeTimeout(emulatePlaylist, 1000));
        }
    })();

    // Random play feature
    (() => {
        // Random play is not supported for mobile devices
        if (location.host === 'm.youtube.com') {
            return;
        }

        const urlParams = new URLSearchParams(window.location.search);

        if (!urlParams.has('ytpa-random') || urlParams.get('ytpa-random') === '0') {
            return;
        }

        /**
         * @type {'random'|'prefer-newest'|'prefer-oldest'}
         */
        const ytpaRandom = urlParams.get('ytpa-random');

        const getStorageKey = () => `ytpa-random-${urlParams.get('list')}`;
        const getStorage = () => JSON.parse(localStorage.getItem(getStorageKey()) || '{}');

        const isWatched = videoId => getStorage()[videoId] || false;
        const markWatched = videoId => {
            localStorage.setItem(getStorageKey(), JSON.stringify({ ...getStorage(), [videoId]: true }));
            document.querySelectorAll(`#wc-endpoint[href*="${videoId}"]`).forEach(
                element => element.parentElement.setAttribute('hidden', ''),
            );
        };

        // Storage needs to now be { [videoId]: bool }
        try {
            if (Array.isArray(getStorage())) {
                localStorage.removeItem(getStorageKey());
            }
        } catch {
            localStorage.removeItem(getStorageKey());
        }

        const playNextRandom = (reload = false) => {
            // prevent the bug that occurs when clicking on the channel from random play
            // and then navigating to videos whilst mini-player is still open
            if (window.location.pathname !== '/watch') {
                return;
            }

            getPlayer().pauseVideo();

            const videos = Object.entries(getStorage()).filter(([_, watched]) => !watched);
            const params = new URLSearchParams(window.location.search);

            // Either one fifth or at most the 20 newest.
            const preferenceRange = Math.max(1, Math.min(Math.min(videos.length * 0.2, 20)));

            let videoIndex;
            switch (ytpaRandom) {
                case 'prefer-newest':
                    // Select between latest 20 videos
                    videoIndex = Math.floor(Math.random() * preferenceRange);

                    break;
                case 'prefer-oldest':
                    // Select between oldest 20 videos
                    videoIndex = Math.max(0, videos.length - 1 - Math.floor(Math.random() * preferenceRange));

                    break;
                default:
                    videoIndex = Math.floor(Math.random() * videos.length);
            }

            if (reload) {
                params.set('v', videos[videoIndex][0]);
                params.set('ytpa-random', ytpaRandom);
                params.delete('t');
                params.delete('index');
                params.delete('ytpa-random-initial');
                window.location.href = `${window.location.pathname}?${params.toString()}`;
            } else {
                // TODO: refactor to the new redirect() function
                const redirector = document.createElement('a');
                redirector.className = 'yt-simple-endpoint style-scope ytd-playlist-panel-video-renderer';
                redirector.setAttribute('hidden', '');
                redirector.data = {
                    'commandMetadata': {
                        'webCommandMetadata': {
                            'url': `/watch?v=${videos[videoIndex][0]}&list=${params.get('list')}&ytpa-random=${ytpaRandom}`,
                            'webPageType': 'WEB_PAGE_TYPE_WATCH',
                            'rootVe': 3832, // ??? required though
                        },
                    },
                    'watchEndpoint': {
                        'videoId': videos[videoIndex][0],
                        'playlistId': params.get('list'),
                    },
                };
                document.querySelector('ytd-playlist-panel-renderer #items').append(redirector);
                redirector.click();
            }
        };

        let isIntervalSet = false;

        const applyRandomPlay = () => {
            if (!window.location.pathname.endsWith('/watch')) {
                return;
            }

            const playlistContainer = document.querySelector('#secondary ytd-playlist-panel-renderer, #below ytd-playlist-panel-renderer ');
            if (playlistContainer === null) {
                return;
            }
            if (playlistContainer.hasAttribute('ytpa-random')) {
                return;
            }

            playlistContainer.setAttribute('ytpa-random', 'applied');
            playlistContainer.insertAdjacentElement(
                'afterbegin',
                $builder('div.ytpa-random-notice').onBuildAppend(
                    'This playlist is using random play.',
                    $builder('br').build(),
                    'The videos will ',
                    $builder('strong').onBuildText('not play in the order').build(),
                    ' listed here.',
                ).build(),
            );

            const storage = getStorage();

            // ensure all the links are "corrected" to random play
            const playlistElementsInterval = safeInterval(() => {
                const elements = playlistContainer.querySelectorAll('a#wc-endpoint:not([href*="&ytpa-random="])');
                if (elements.length === 0) {
                    clearInterval(playlistElementsInterval);

                    return;
                }

                elements.forEach(element => {
                    const videoId = (new URLSearchParams(new URL(element.href).searchParams)).get('v');
                    if (!isWatched(videoId)) {
                        storage[videoId] = false;
                    }

                    element.href += '&ytpa-random=' + ytpaRandom;
                    // This bypasses the client side routing
                    safeEventListener(element, 'click', event => {
                        event.preventDefault();

                        window.location.href = element.href;
                    });

                    const entryKey = getVideoId(element.href);
                    if (isWatched(entryKey)) {
                        element.parentElement.setAttribute('hidden', '');
                    }
                });
            }, 1000);
            localStorage.setItem(getStorageKey(), JSON.stringify(storage));

            if (urlParams.get('ytpa-random-initial') === '1' || isWatched(getVideoId(location.href))) {
                playNextRandom();

                return;
            }

            safeEventListener(document, 'keydown', event => {
                // SHIFT + N
                if (event.shiftKey && event.key.toLowerCase() === 'n') {
                    event.stopImmediatePropagation();
                    event.preventDefault();

                    const videoId = getVideoId(location.href);
                    markWatched(videoId);
                    // Unfortunately there is no workaround to YouTube redirecting to the next in line without a reload
                    playNextRandom(true);
                }
            }, true);

            if (isIntervalSet) {
                return;
            }
            isIntervalSet = true;

            safeInterval(() => {
                const videoId = getVideoId(location.href);

                const params = new URLSearchParams(location.search);
                params.set('ytpa-random', ytpaRandom);
                window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);

                const player = getPlayer();
                const progressState = player.getProgressState();

                // Do not listen for watch progress when watching advertisements
                if (!isAdPlaying()) {
                    if (progressState.current / progressState.duration >= 0.9) {
                        markWatched(videoId);
                    }

                    // Autoplay random video
                    if (progressState.current >= progressState.duration - 3) {
                        // make sure vanilla autoplay doesn't take over
                        player.pauseVideo();
                        player.seekTo(0);
                        playNextRandom();
                    }
                }

                const nextButton = document.querySelector('#ytd-player .ytp-next-button.ytp-button:not([ytpa-random="applied"])');
                if (nextButton) {
                    // Replace with span to prevent anchor click events
                    const newButton = nextButton.cloneNode(true);
                    newButton.href = 'javascript:void(0)';
                    nextButton.replaceWith(newButton);

                    newButton.setAttribute('ytpa-random', 'applied');
                    safeEventListener(newButton, 'click', event => {
                        markWatched(videoId);
                        playNextRandom();
                    });
                }
            }, 500);
        };

        safeInterval(applyRandomPlay, 1000);
    })();
})(() => {
    // --- Modules, aka. the big refactor, aka. I spent too much time with Purescript ---

    // This might look absolutely insane, and I agree.
    // But it is the best way to go "enterprise userscript", while keeping it single file.
    // Let's all admit already, this thing is not your average userscript!

    // The best part? Jetbrains-IDEs are smart enough to resolve all of this.

    const Id = (() => {
        const newUuid = () => crypto.randomUUID();

        const namespace = ns => {
            const newHtmlId = () => `ytpa-${ns}-${newUuid()}`;

            return {
                newHtmlId,
            };
        };

        return {
            newUuid,
            namespace,
            default: namespace('default'),
        };
    })();

    const  Obj = (() => {
        const isObject = item => (item && typeof item === 'object' && !Array.isArray(item));

        // https://stackoverflow.com/a/48218209
        const merge = (...objects) => {
            return objects.reduce((prev, obj) => {
                Object.keys(obj).forEach(key => {
                    const pVal = prev[key];
                    const oVal = obj[key];

                    if (Array.isArray(pVal) && Array.isArray(oVal)) {
                        prev[key] = pVal.concat(...oVal);
                    }
                    else if (isObject(pVal) && isObject(oVal)) {
                        prev[key] = merge(pVal, oVal);
                    }
                    else {
                        prev[key] = oVal;
                    }
                });

                return prev;
            }, {});
        };

        const watch = (rootObject, notify) => {
            const cache = new WeakMap();

            const proxify = (object) => {
                if (!isObject(object)) return object;

                const existing = cache.get(object);
                if (existing) return existing;

                const handler = {
                    get(target, prop, receiver) {
                        return proxify(Reflect.get(target, prop, receiver));
                    },
                    set(target, prop, value, receiver) {
                        value = proxify(value);
                        const ok = Reflect.set(target, prop, value, receiver);
                        if (ok) notify();

                        return ok;
                    },
                };

                return new Proxy(object, handler);
            };

            return proxify(rootObject);
        };

        return {
            isObject,
            merge,
            watch,
        };
    })()

    const ControlFlow = (() => {
        /**
         * The universal sink.
         * There is nothing it doesn't tolerate!
         *
         * _.x(1)[_ - 10]({ _ })[_._] = '???';
         *
         * If anyone ever, anywhere, needs this, feel free to use, it's MIT.
         * Keep the jsdoc if possible to avoid needing an separate license file.
         *
         * MIT License — Copyright (c) 2026 Robert Wesner
         *
         * Permission is hereby granted, free of charge, to any person obtaining a copy
         * of this software and associated documentation files (the "Software"), to deal
         * in the Software without restriction, including without limitation the rights
         * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
         * copies of the Software, and to permit persons to whom the Software is
         * furnished to do so, subject to the following condition:
         * The above copyright notice and this permission notice shall be included in
         * all copies or substantial portions of the Software.
         * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
         *
         * Godspeed.
         *
         * @type {any}
         *
         * @license MIT
         * @author Robert Wesner
         * @since 2026-02-20 22:20:13
         */
        const _ = new Proxy(() => {}, {
            apply: () => _,
            construct: () => _,
            set: () => true,
            get(target, prop) {
                return {
                    [Symbol.toPrimitive]: () => '',
                    'toString': () => '',
                    'valueOf': () => 0,
                    'then': undefined,
                }[prop] ?? _;
            },
        });

        try {
            // I thought I actually "needed" (= wanted) it to avoid multiple () => {}
            // Then I let it escalate slightly...
            // Until I realized I didn't need the () => {} anymore.
            // My Go & Purescript brain just really wanted that!
            // Anyhow, now its officially part of my own little stdlib.
            // UPDATE: It was very useful indeed.
            (() => _.x(1)[_ - 10]({ _ })[_._])(_)._ = _._('???')._._._._['_'];
        } catch (e) {
            console.error('The hole has failed us.', e);
        }

        const pass = x => x;

        return { _, pass };
    })();

    const Fmt = (() => {
        const trimIndent = string => {
            const lines = string.replace(/^\n/, '').split('\n');

            const indent= Math.min(
                ...lines
                    .filter(line => line.trim())
                    .map(line => line.match(/^(\s*)/)[1].length),
            );

            return lines.map(line => line.slice(indent)).join('\n');
        };

        const ucfirst = text => {
            return String(text).charAt(0).toUpperCase() + String(text).slice(1);
        };

        return { trimIndent, ucfirst };
    })();

    const HtmlCreation = (() => {
        /**
         * Safely dynamically create HTML-Elements.
         *
         * Creates actual DOM Elements, not plain HTML.
         *
         * Example:
         *  $builder('div')
         *      .id('foo')
         *      .className('a b c')
         *      .onBuildText('<b>Test</b>')
         *      .build()
         *
         * Result:
         *  ```
         *  <div id="foo" class="a b c">
         *      &lt;b&gt;Test&lt;/b&gt;
         *  </div>;
         *  ```
         *
         * @return WrappedElementBuilder
         */
        const $builder = (query, ns = null) => {
            /**
             * @param {HTMLElement} element
             * @return {HTMLElement&WrappedElementBuilder}
             */
            const proxy = element => {
                let postBuildOperations = [];
                const getOperations = () => {
                    const operations = [...postBuildOperations.flat()];
                    postBuildOperations = [];

                    return operations;
                };

                const instance = new Proxy(element, {
                    get(target, prop, _) {
                        const P = operation => (...xs) => {
                            operation(...xs);

                            return instance;
                        };
                        const PBO = operation => P((...xs) => postBuildOperations.push(() => operation(...xs)));

                        switch (prop) {
                            case 'build':
                                return () => {
                                    getOperations().forEach(operation => operation(element));

                                    return element;
                                }
                            case 'buildWithSync':
                                return async () => {
                                    for (const operation of getOperations()) {
                                        const result = operation(element);
                                        if (result && typeof result.then === 'function') {
                                            await result;
                                        }
                                    }

                                    return element;
                                };
                            case 'addClass':
                                return P(x => element.classList.add(x));
                            case 'onBuild':
                                return P(x => postBuildOperations.push(x));
                            case 'onBuildAppend':
                                return PBO((...xs) => element.append(...xs));
                            case 'onBuildText':
                                return PBO(x => element.textContent = x);
                            case 'on':
                                return P((event, handler) => element.addEventListener(event, handler));
                        }

                        const alwaysUseAttributes = ['hidden', 'style'];

                        return value => {
                            if (!alwaysUseAttributes.includes(prop) && prop in element) {
                                element[prop] = value;
                            } else {
                                element.setAttribute(prop.replaceAll('_', '-'), value);
                            }

                            return instance;
                        };
                    },
                });

                return instance;
            };

            /**
             * Does NOT call build().
             * Instead returns a builder instance!
             *
             * Example:
             *  parseQuery(`button#foo.a.b.c[aria-label="${label}"]`)
             *
             * Result:
             *  $builder('button')
             *      .id('foo')
             *      .className('a b c')
             *      .aria_label(`${label}`)
             *
             * @param query
             * @return {any|HTMLElement}
             */
            const parseQuery = query => {
                const match = query.match(/^[a-zA-Z0-9-]+/);
                if (!match) {
                    throw 'Invalid tag supplied to parseQuery.';
                }
                const tag = match[0];

                const builder = proxy(document.createElement(tag));
                if (!query.match(/^[a-zA-Z0-9-]+$/)) {
                    const split = text => text.match(/^[a-zA-Z0-9-]+((?:[#.][a-zA-Z0-9-]+)+)?((?:\[[a-zA-Z0-9-]+(?:=".*?")?])+)?/);

                    const result = split(query);
                    const basic = result[1] ?? '';
                    const attributes = result[2] ?? '';

                    basic.matchAll(/([#.])([a-zA-Z0-9-]+)/g).forEach(([ignore, type, value]) => {
                        ({
                            '#': builder.id,
                            '.': builder.addClass,
                        })[type](value);
                    });

                    attributes.matchAll(/\[([a-zA-Z0-9-]+)(?:="(.*?)")?]/g).forEach(([ignore, key, value]) => {
                        builder[key](value);
                    });
                }

                return builder;
            };

            return parseQuery(query);
        };

        const $style = (id, style) => {
            return document.head.insertAdjacentElement(
                'beforeend',
                $builder(`style#${id}`)
                    .onBuild(element => element.textContent = style)
                    .build(),
            );
        };

        return { $builder, $style };
    })();

    const Console = (() => {
        const templates = {
            default: [
                '%cYTPA - YouTube Play All%c\n',
                'color: #bf4bcc; font-size: 26px; font-weight: bold',
                '',
            ],
            debug: [
                '%cDEBUG%c\n',
                'color: #1aff00; font-size: 48px; font-weight: bold',
                '',
            ],
        };

        const createLogger = (fn, template) => (...messages) => {
            if (typeof messages[0] === "string") {
                fn(template[0] + messages[0], ...template.slice(1), ...messages.slice(1));
            } else {
                fn(...template, ...messages);
            }
        };

        return {
            info: createLogger(console.info, templates.default),
            error: createLogger(console.error, templates.default),
            log: createLogger(console.debug, templates.debug),
        };
    })();

    const Safety = (() => {
        const console = Console;

        const handleError = e => console.error(e);
        const attachSafetyListener = () => {
            window.addEventListener('unhandledrejection', event => {
                const e = event.reason || event;
                const stack = (e && e.stack) || '';

                if (!stack || !stack.includes('__ytpa_root_call__')) {
                    return;
                }

                handleError(e);
            });
        };

        const safeWrapCall = fn => ((...args) => {
            try {
                let result = fn(...args);
                if (result instanceof Promise) {
                    result = result.catch(handleError);
                }

                return result;
            } catch (e) {
                handleError(e);
            }
        });

        const safeTimeout = (fn, duration) => setTimeout(safeWrapCall(fn), duration);
        const safeInterval = (fn, duration) => setInterval(safeWrapCall(fn), duration);
        const safeEventListener = (node, event, fn) => node.addEventListener(event, safeWrapCall(fn));

        return {
            handleError,
            attachSafetyListener,
            safeTimeout,
            safeInterval,
            safeEventListener,
        };
    })();

    const AsyncOperations = (() => {
        const waitForElement = async (selector, { root = document } = {}) => new Promise(resolve => {
            const select = () => root.querySelector(selector), existing = select();
            if (existing) return resolve(existing);

            const observer = new MutationObserver(() => {
                const existing = select();
                if (existing) {
                    observer.disconnect();
                    resolve(existing);
                }
            });

            observer.observe(root, { childList: true, subtree: true });
        });

        return { waitForElement };
    })();

    const Versioned = (() => {
        const { waitForElement } = AsyncOperations;

        const v20260220 = {
            /**
             * Latest / Popular / Oldest
             *
             * Compatible with the new members-only UI.
             */
            getTypeButtons: async () => new Promise((resolve) => {
                const dropdownButton = document.querySelector('chip-bar-view-model.ytChipBarViewModelHost div.ytChipBarViewModelChipWrapper:has(.ytIconWrapperHost.ytChipShapeIconEnd)');
                if (dropdownButton) {
                    dropdownButton.addEventListener('click', () => {
                        waitForElement('tp-yt-iron-dropdown.style-scope.ytd-popup-container:not([hidden], [style*="display: none"]) yt-sheet-view-model')
                            .then(element => resolve(element.querySelectorAll('yt-list-item-view-model')))
                    });
                } else {
                    resolve(document.querySelectorAll('chip-bar-view-model.ytChipBarViewModelHost div.ytChipBarViewModelChipWrapper'));
                }
            }),
        };

        return { v20260220 };
    })();

    const Greeter = (() => {
        const console = Console;

        const style = x => 'font-family: sans-serif; font-size: 16px;' + x;
        const greet = debugObject => console.info(
            Fmt.trimIndent(`
                %cHi there!
                Thank you for using YTPA.
                
                Please report problems at:
                %chttps://rwe.ms/ytpa-issues%c
                
                If you have a free minute, consider answering a few non-intrusive questions:
                %chttps://rwe.ms/ytpa-feedback%c
                
                If anything breaks, make sure to attach this to your issue:
            `) + '%c' + JSON.stringify(debugObject, null, 2),
            style(),
            style('color: aqua'),
            style(),
            style('color: aqua'),
            style(),
            'color: #11ff00',
        );

        return { greet };
    })();

    const Dialog = (() => {
        const { $builder } = HtmlCreation;

        const newDialog = () => {
            /**
             * @var {HTMLDivElement}
             */
            const title = $builder('div.ytpa-dialog-title[role="heading"]').build();
            /**
             * @var {HTMLDivElement}
             */
            const body = $builder('div.ytpa-dialog-body').build();

            const build = () => $builder('dialog.ytpa-dialog').onBuildAppend(
                    $builder('div.ytpa-dialog-head').onBuildAppend(
                        title,
                        $builder('form[method="dialog"]').onBuildAppend(
                            $builder('button').className('ytpa-dialog-close-btn').onBuild(
                                button => button.textContent = '×',
                            ).build(),
                        ).build(),
                    ).build(),
                    body,
                ).build();
            /**
             * @var {HTMLDialogElement}
             */
            const element = build();
            document.querySelector('ytd-app').insertAdjacentElement('beforeend', element);

            return {
                _valid: true,
                title,
                body,
                element,
                ensureValid() {
                    if (!this._valid) throw 'One shall not re-use a removed dialog!';
                },
                setTitle(text) {
                    this.title.textContent = text;
                },
                setContent(...elements) {
                    this.body.textContent = '';
                    this.body.append(...elements);

                    return this;
                },
                show() {
                    this.ensureValid();
                    this.element.showModal();

                    return this;
                },
                hide() {
                    this.ensureValid();
                    this.element.close();

                    return this;
                },
                remove() {
                    this.element.remove();
                    this._valid = false;
                },
                // the fancy stuff
                async done() {
                    return new Promise(resolve => {
                        // listener first to mitigate data race
                        this.element.addEventListener('close', () => resolve());
                        if (!this.element.open) {
                            resolve();
                        }
                    })
                },
                /**
                 * @param {string} title
                 * @param {(push: (HTMLElement) => HTMLElement) => {}} run
                 * @return {Promise}
                 */
                async with(title, run) {
                    return new Promise(resolve => {
                        /** @var {HTMLElement[]} */
                        const elements = [];
                        const push = element => {
                            elements.push(element);

                            return element;
                        };

                        run(push);
                        this.setTitle(title)
                        this.setContent(...elements);
                        this.show();
                        this.done().then(() => {
                            this.hide();
                            resolve();
                        });
                    });
                },
            };
        }

        return { newDialog };
    })();

    const ValuesDialogComponent = (() => {
        const { _ } = ControlFlow;

        const getDefaultHooks = () => [hookHelp, hookConfirm];

        const base = x => ({
            of: x,
            hooked: {},
        });

        // no one can stop me from currying
        const H = key => x => [key, addition => {
            x.hooked[key] = addition;

            return x;
        }];
        const hookTest = H('test');
        const hookHelp = H('help');
        const hookConfirm = H('confirm');

        const S = (t, marker, hooks = []) => (x = null) => {
            const result = { ...base(x), m: marker};
            [...getDefaultHooks(), ...hooks].forEach(hook => {
                const [key, w] = hook(result);
                result[`with${Fmt.ucfirst(key)}`] = w;
            });
            result.value = t.initial;

            return result;
        };

        // asX returns a terminal object (no further DSL chaining)
        const DSL = {
            /**
             * @return {ComponentDummy}
             */
            asDummy() {
                // noinspection JSValidateTypes
                return S(this, { dummy: _ }, [hookTest])();
            },
            /**
             * @return {ComponentText}
             */
            asText() {
                // noinspection JSValidateTypes
                return S(this, { text: _ })();
            },
            /**
             * @return {ComponentTextarea}
             */
            asTextarea() {
                // noinspection JSValidateTypes
                return S(this, { textarea: _ })();
            },
            /**
             * @return {ComponentPassword}
             */
            asPassword() {
                // noinspection JSValidateTypes
                return S(this, { password: _ })();
            },
            /**
             * @return {ComponentNumber}
             */
            asNumber() {
                // noinspection JSValidateTypes
                return S(this, { number: _ })();
            },
            /**
             * @return {ComponentToggle}
             */
            asToggle() {
                // noinspection JSValidateTypes
                return S(this, { toggle: _ })();
            },
            /**
             * @param {_Component_Dsl_Param_ArrayObject} xs
             * @return {ComponentOneOf}
             */
            asOneOf(...xs) {
                // noinspection JSValidateTypes
                return S(this, { oneOf: _ })(...xs);
            },
            /**
             * @param {_Component_Dsl_Param_ArrayObject} xs
             * @return {ComponentAnyOf}
             */
            asAnyOf(...xs) {
                // noinspection JSValidateTypes
                return S(this, { anyOf: _ })(...xs);
            },
        };

        const ofInitial = x => {
            const result = Object.create(DSL);
            result.initial = x;

            return result;
        };

        const collection = () => {
            const map = new Map();

            const obj = { map };
            obj.set = (k, v) => {
                map.set(k, v);

                return obj;
            };

            return obj;
        };

        const key = (name, displayText) => ({ name, displayText });

        return {
            ofInitial,
            collection,
            key,
        };
    })();

    const ValuesDialog = (() => {
        const { _ } = ControlFlow;
        const { $builder } = HtmlCreation;
        const { newDialog } = Dialog;

        const ns = 'dialog-component';
        const baseClassName = `ytpa-${ns}`;
        const IdNamespace = Id.namespace(ns);

        /**
         * @param {ValueDialogComponents} components
         * @return {HTMLElement[]}
         */
        const createElements = components => components.map.entries().toArray().map(
            /**
             * @param {string} name
             * @param {string} displayText
             * @param {ComponentT} component
             * @param i
             * @return {HTMLElement}
             */
            ([{ name, displayText }, component], i) => $builder(`div.${baseClassName}-container`).onBuild(
                container => {
                    const id = IdNamespace.newHtmlId();
                    const helpId = `${id}-help`;

                    const init = element => component.value !== undefined && (
                        component.m.toggle
                            ? (element.checked = !!component.value)
                            : (element.value = component.value)
                    );
                    const $b = tag => $builder(tag).className(baseClassName);
                    const build = builder => builder
                        .id(id)
                        .name(name)
                        .data_index(i.toString())
                        .onBuild(init)
                        .build();

                    const has = x => x !== undefined;

                    /** @var {HTMLElement|boolean} */
                    let result = !_
                        || (has(component.m.dummy) && build($b('div').onBuildText('Click me!').on(
                            'click',
                            (event) => event.target.append(component.hooked.test),
                        )))
                        || (has(component.m.text) && build($b('input[type="text"]').on(
                            'input',
                            (event) => component.value = event.target.value,
                        )))
                        || (has(component.m.textarea) && build($b('textarea').on(
                            'input',
                            (event) => component.value = event.target.value,
                        )))
                        || (has(component.m.password) && build($b('input[type="password"]').on(
                            'input',
                            (event) => component.value = event.target.value,
                        )))
                        || (has(component.m.number) && build($b('input[type="number"]').on(
                            'input',
                            (event) => component.value = event.target.value,
                        )))
                        || (has(component.m.oneOf) && build($b('select')
                            .on(
                                'change',
                                event => component.value = event.target.value,
                            )
                            .onBuildAppend(
                                ...Object.entries(component.of).map(
                                    ([k, v]) => {
                                        const option = $builder('option')
                                            .value(k)
                                            .onBuildText(v);
                                        if (k === component.value) {
                                            option.selected('');
                                        }

                                        return option.build();
                                    }
                                ),
                            )
                        ))
                        // TODO: finish with multiple choice checkboxes!
                        || (has(component.m.anyOf) && build($builder('div')
                            .id(id)
                            .name(name)
                            .className(`${baseClassName}-WIP`)
                            .onBuildText('UNIMPLEMENTED')
                            .data_index(i.toString())
                        ));

                    if (typeof result === 'object') {
                        // wrap the stuff into a div with label
                        result = $builder('div').onBuildAppend(
                            $builder('label').for(id).onBuildText(displayText).build(),
                            result,
                        ).build();
                    } else if (has(component.m.toggle)) {
                        // checkboxes are built more custom
                        result = build(
                            $builder('label')
                                .id(id)
                                .name(name)
                                .className(baseClassName)
                                .onBuildAppend(
                                    $builder('div.switch')
                                        .onBuildAppend(
                                            build($builder('input[type="checkbox"]')),
                                            $builder('span.slider').aria_hidden('true').build(),
                                        )
                                        .build(),
                                    $builder('div.text')
                                        .onBuildText(displayText)
                                        .build(),
                                )
                                .on(
                                    'change',
                                    (event) => {
                                        if (
                                            !event.target.checked
                                            && component.hooked.confirm
                                            && (!confirm(component.hooked.confirm))
                                        ) {
                                            event.preventDefault();
                                            event.target.checked = true;

                                            return;
                                        }

                                        component.value = event.target.checked;
                                    },
                                )
                                .data_index(i.toString())
                        );
                    }

                    if (typeof result === 'boolean') {
                        throw 'Could not build ValuesDialog component.';
                    }

                    container.append(result);
                    if (has(component.hooked.help)) {
                        container.append(
                            $builder(`div`)
                                .id(helpId)
                                .className(`${baseClassName}-help`)
                                .onBuildText(component.hooked.help)
                                .build(),
                        );
                        result.setAttribute('aria-describedby', helpId);
                    }
                },
            ).build(),
        );

        /**
         * @param {ValueDialogComponents} components
         * @return {Promise<{ [key: string]: any }>}
         */
        const show = async (components) => newDialog()
            .with('YTPA Components', push => {
                createElements(components).forEach(push);
            })
            .then(() => Object.fromEntries(
                components.map.entries().map(([k, v]) => [k.name, v.value]),
            ));

        return { show };
    })();

    // solves the slight "circular" dependency with a promise
    let setSettingsStorage = () => { throw 'Premature call to setSettingsStorage.' };
    /** @type {Promise<SettingsStorage>} */
    const settingsStoragePromise = new Promise(resolve => setSettingsStorage = resolve);

    const SettingsHandlers = (() => {
        const uiSettingsSlug = 'ytpa-ui-setting';

        /**
         * @param {() => string[]} pull
         * @param {([]) => void} push
         * @param {() => void} clear
         */
        const settingOf = (pull, push, clear) => ({
            has: x => pull().includes(x),
            add: (...x) => {
                const settings = pull();
                x.forEach(y => settings.includes(y) || settings.push(y));
                push(settings);
            },
            remove: x => {
                const settings = pull();
                const index = settings.indexOf(x);
                if (index <= -1) return;

                settings.splice(index, 1);
                push(settings);
            },
            clear,
        });

        const settings = {
            ui: settingOf(
                () => document.documentElement.getAttribute(uiSettingsSlug)?.split(' ') ?? [],
                raw => document.documentElement.setAttribute(uiSettingsSlug, raw.join(' ')),
                () => document.documentElement.setAttribute(uiSettingsSlug, ''),
            ),
        };

        /**
         * @param {{ [key: string]: [string] }} loadedSettings
         */
        const update = loadedSettings => Object.keys(loadedSettings)
            .forEach(key => {
                settings[key].clear();
                settings[key].add(...loadedSettings[key].filter(x => !!x));
            });

        // This is how we translate storage data into settings.
        const updateByStorageData = async () => settingsStoragePromise.then(
            /**
             * @param {{ data: () => SettingsData }} storage
             */
            storage => {
                const { general } = storage.data();

                update({
                    ui: [
                        general.ui.buttonTheme,
                        general.ui.spacerVisible
                            && G.s.ui.spacer.show,
                        general.ui.settingsButtonVisible
                            && G.s.ui.settings.button.show,
                    ],
                });
            },
        );
        updateByStorageData().then();

        return { updateByStorageData };
    })();

    const SettingsStorage = (async () => {
        const gmKey = 'settings';

        /** @var {Settings|null} */
        let cachedSettings = null;

        /**
         * @param label
         * @param {(object) => object} migration
         * @return {function(*): *}
         */
        const M = (label, migration) => previous => {
            Object.freeze(previous);
            const current = migration(previous);

            if (!current || typeof current !== 'object') {
                throw `Migration ${label} produced invalid result.`;
            }

            if (current === previous) {
                throw `Migration ${label} did not return a new instance.`
            }

            return current;
        };

        const load = async () => {
            cachedSettings = migrate(await GM.getValue(gmKey, {}));
        };
        const sync = async () => {
            if (cachedSettings !== null) {
                return GM.setValue(gmKey, cachedSettings);
            }
        };

        // make sure to never change any of these post-release
        // unless critically broken, a new migration is the preferred way
        const migrations = [
            () => ({ version: 0, data: {} }),
            // 2026.02.21
            M('initial', previous => Obj.merge(previous, {
                data: {
                    general: {
                        ui: {
                            buttonTheme: G.s.ui.button.theme.adaptiveOutline,
                            spacerVisible: true,
                            settingsButtonVisible: true,
                        },
                    },
                },
            })),
        ];

        const migrate = previous => {
            const currentVersion = previous.version ?? 0;
            if (currentVersion in migrations) {
                const result = migrations[currentVersion](previous);
                result.version = currentVersion + 1;

                return migrate(result);
            }

            return previous;
        };

        /**
         * @return {SettingsData|{}}
         */
        const data = () => Obj.watch(
            cachedSettings?.data ?? {},
            () => sync(),
        );
        const clear = async () => {
            await GM.deleteValue(gmKey);
            await sync();
            window.location.reload();
        };

        await load();

        /**
         * @typedef {{
         *  get: () => SettingsData,
         *  set: (k: string, v: any) => Promise,
         *  clear: () => Promise,
         * }} SettingsStorage
         */
        const exports = {
            data,
            clear,
        };
        setSettingsStorage(exports);

        return exports;
    })();

    const SettingsDialog = (async () => {
        const console = Console;
        /** @var {SettingsData} */
        const data = (await settingsStoragePromise).data();

        const components = (() => {
            const Component = ValuesDialogComponent;

            return Component
                .collection()
                .set(Component.key('buttonTheme', 'Theme of the "Play All"-button'), Component
                    .ofInitial(data.general.ui.buttonTheme)
                    .asOneOf({
                        [G.s.ui.button.theme.classic]: 'Classic',
                        [G.s.ui.button.theme.adaptive]: 'Adaptive',
                        [G.s.ui.button.theme.adaptiveOutline]: 'Adaptive with outline',
                    }),
                )
                .set(Component.key('spacerVisible', 'Show spacer before buttons'), Component
                    .ofInitial(data.general.ui.spacerVisible)
                    .asToggle(),
                )
                .set(Component.key('settingsButtonVisible', 'Show settings button'), Component
                    .ofInitial(data.general.ui.settingsButtonVisible)
                    .asToggle()
                    .withHelp(Fmt.trimIndent(`
                        Disabling this setting may prevent you from opening this window!
                        Do not disable if you are unable to open the browser console.
                        
                        To open the settings via console, use:
                        YTPA_tools.showSettings();
                    `))
                    .withConfirm('Are you sure you want to disable te menu button?\nYou might not be able to restore it!'),
                )
            ;
        })();

        const show = () => {
            ValuesDialog.show(components).then(values => {
                const {
                    buttonTheme,
                    spacerVisible,
                    settingsButtonVisible,
                } = values;

                if (Object.values(G.s.ui.button.theme).includes(buttonTheme)) {
                    data.general.ui.buttonTheme = buttonTheme;
                } else {
                    console.error(`Invalid button theme ${buttonTheme}.`);
                }

                data.general.ui.spacerVisible = spacerVisible;
                data.general.ui.settingsButtonVisible = settingsButtonVisible;

                SettingsHandlers.updateByStorageData();
            });
        };

        return {
            show,
        };
    })();

    const Debug = (() => {
        const YTPA_tools = {
            storage: () => { throw 'Storage not loaded.'; },
            showSettings: () => { throw 'Settings not ready.'; },
        };

        settingsStoragePromise.then(storage => YTPA_tools.storage = storage);
        SettingsDialog.then(dialog => YTPA_tools.showSettings = dialog.show);

        return {
            YTPA_tools,
        };
    })();

    return [{
        Id,
        Obj,
        ControlFlow,
        Fmt,
        HtmlCreation,
        Console,
        Safety,
        AsyncOperations,
        Versioned,
        Greeter,
        Dialog,
        ValuesDialogComponent,
        ValuesDialog,
        SettingsHandlers,
        Debug
    }, {
        SettingsStorage,
        SettingsDialog,
    }];
}, () => {
    const s = G.s.ui;
    const ifUi = setting => `html[ytpa-ui-setting~="${setting}"]`;

    return [
        ['ytpa-btn-height', ''],
        ['ytpa-base', /* language=css */ `
            html {
                /* Keep these in mind for UI theming */
                --ytpa-bg-base: var(--yt-spec-base-background);
                --ytpa-bg-raised: var(--yt-spec-raised-background);
                --ytpa-bg-menu: var(--yt-spec-menu-background);
                --ytpa-bg-additive: var(--yt-spec-additive-background);
                --ytpa-bg-additive-inverse: var(--yt-spec-additive-background-inverse);
                --ytpa-fg-primary: var(--yt-spec-text-primary);
                --ytpa-fg-secondary: var(--yt-spec-text-secondary);
                --ytpa-fg-disabled: var(--yt-spec-text-disabled);
                --ytpa-cta: var(--yt-spec-call-to-action);
                /*--yt-spec-overlay-button-primary:rgba(255,255,255,0.3);*/
                /*--yt-spec-overlay-button-secondary:rgba(255,255,255,0.1);*/
                /*--yt-spec-overlay-button-secondary-darker:rgba(255,255,255,0.2);*/
                
                --ytpa---base-1: rgba(255, 255, 255, 0.064);
                --ytpa---base-2: rgba(0, 0, 0, 0.128);
            }
    
            html[dark] {
                --ytpa-bg-additive-heavy: var(--ytpa---base-2);
                --ytpa-bg-additive-inverse-heavy: var(--ytpa---base-1);
            }
    
            html:not([dark]) {
                --ytpa-bg-additive-heavy: var(--ytpa---base-1);
                --ytpa-bg-additive-inverse-heavy: var(--ytpa---base-2);
            }
        `],
        ['ytpa-style', /* language=css */ `
            .ytpa-btn {
                border-radius: 8px;
                font-family: 'Roboto', 'Arial', sans-serif;
                font-size: 1.4rem;
                line-height: 2rem;
                font-weight: 500;
                margin-left: 0.6em; /* this might be obsolet in new UI, see below */
                user-select: none;
                display: inline-flex;
                flex-direction: column;
                justify-content: center;
                vertical-align: top;
                padding: 0 0.5em;
                /*noinspection CssUnresolvedCustomProperty*/
                height: var(--ytpa-btn-height);
            }
    
            .ytpa-btn, .ytpa-btn > * {
                text-decoration: none;
                cursor: pointer;
            }
    
            .ytpa-btn-sections {
                padding: 0;
                flex-direction: row;
            }
    
            .ytpa-btn-sections > .ytpa-btn-section {
                display: flex;
                flex-direction: column;
                justify-content: center;
                vertical-align: top;
                padding: 0 0.5em;
            }
    
            .ytpa-btn-sections > .ytpa-btn-section:first-child {
                border-top-left-radius: 8px;
                border-bottom-left-radius: 8px;
            }
    
            .ytpa-btn-sections > .ytpa-btn-section:nth-last-child(1 of .ytpa-btn-section) {
                border-top-right-radius: 8px;
                border-bottom-right-radius: 8px;
            }
    
            .ytpa-random-popover {
                position: absolute;
                border-radius: 8px;
                font-size: 1.6rem;
                transform: translate(-100%, 0.4em);
                z-index: 10000;
            }
    
            .ytpa-random-popover > * {
                display: block;
                text-decoration: none;
                padding: 0.4em;
            }
    
            .ytpa-random-popover > :first-child {
                border-top-left-radius: 8px;
                border-top-right-radius: 8px;
            }
    
            .ytpa-random-popover > :last-child {
                border-bottom-left-radius: 8px;
                border-bottom-right-radius: 8px;
            }
    
            .ytpa-random-popover > *:not(:last-child) {
                border-bottom: 1px solid #6e8dbb;
            }
    
            .ytpa-button-container {
                display: flex;
                width: 100%;
                margin-top: 1em;
                margin-bottom: -1em;
            }
    
            ytd-rich-grid-renderer .ytpa-button-container > :first-child {
                margin-left: 0;
            }
    
            /* fetch() API introduces a race condition. This hides the occasional duplicate buttons */
            .ytpa-play-all-btn ~ .ytpa-play-all-btn,
            .ytpa-random-btn ~ .ytpa-random-btn {
                display: none;
            }
    
            /* Fix for mobile view */
            ytm-feed-filter-chip-bar-renderer .ytpa-btn {
                margin-right: 12px;
                padding: 0 0.4em;
                display: inline-flex !important;
            }
    
            body:has(#secondary ytd-playlist-panel-renderer[ytpa-random]) .ytp-prev-button.ytp-button,
            body:has(#secondary ytd-playlist-panel-renderer[ytpa-random]) .ytp-next-button.ytp-button:not([ytpa-random="applied"]),
            body:has(#below ytd-playlist-panel-renderer[ytpa-random]) .ytp-prev-button.ytp-button,
            body:has(#below ytd-playlist-panel-renderer[ytpa-random]) .ytp-next-button.ytp-button:not([ytpa-random="applied"]) {
                display: none !important;
            }
    
            #secondary ytd-playlist-panel-renderer[ytpa-random] ytd-menu-renderer.ytd-playlist-panel-renderer,
            #below ytd-playlist-panel-renderer[ytpa-random] ytd-menu-renderer.ytd-playlist-panel-renderer {
                height: 1em;
                visibility: hidden;
            }
    
            #secondary ytd-playlist-panel-renderer[ytpa-random]:not(:hover) ytd-playlist-panel-video-renderer,
            #below ytd-playlist-panel-renderer[ytpa-random]:not(:hover) ytd-playlist-panel-video-renderer {
                filter: blur(2em);
            }
    
            .ytpa-random-notice {
                padding: 1em;
                z-index: 1000;
            }
    
            .ytpa-playlist-emulator {
                margin-bottom: 1.6rem;
                border-radius: 1rem;
            }
    
            .ytpa-playlist-emulator > .title {
                border-top-left-radius: 1rem;
                border-top-right-radius: 1rem;
                font-size: 2rem;
                background-color: #323232;
                color: white;
                padding: 0.8rem;
            }
    
            .ytpa-playlist-emulator > .information {
                font-size: 1rem;
                background-color: #2b2a2a;
                color: white;
                padding: 0.8rem;
            }
    
            .ytpa-playlist-emulator > .footer {
                border-bottom-left-radius: 1rem;
                border-bottom-right-radius: 1rem;
                background-color: #323232;
                padding: 0.8rem;
            }
    
            .ytpa-playlist-emulator > .items {
                max-height: 500px;
                overflow-y: auto;
                overflow-x: hidden;
            }
    
            .ytpa-playlist-emulator:not([data-failed]) > .items:empty::before {
                content: 'Loading playlist...';
                background-color: #626262;
                padding: 0.8rem;
                color: white;
                font-size: 2rem;
                display: block;
            }
    
            .ytpa-playlist-emulator[data-failed="rejected"] > .items:empty::before {
                content: "Make sure to allow the external API call to ytplaylist.robert.wesner.io to keep viewing playlists that YouTube doesn't natively support!";
                background-color: #491818;
                padding: 0.8rem;
                color: #ff7c7c;
                font-size: 1rem;
                display: block;
            }
    
            .ytpa-playlist-emulator > .items > .item {
                background-color: #2c2c2c;
                padding: 0.8rem;
                border: 1px solid #1b1b1b;
                font-size: 1.6rem;
                color: white;
                min-height: 5rem;
                cursor: pointer;
            }
    
            .ytpa-playlist-emulator > .items > .item:hover {
                background-color: #505050;
            }
    
            .ytpa-playlist-emulator > .items > .item:not(:last-of-type) {
                border-bottom: 0;
            }
    
            .ytpa-playlist-emulator > .items > .item[data-current] {
                background-color: #767676;
            }
    
            body:has(.ytpa-playlist-emulator) .ytp-prev-button.ytp-button,
            body:has(.ytpa-playlist-emulator) .ytp-next-button.ytp-button:not([ytpa-emulation="applied"]) {
                display: none !important;
            }
    
            /* hide when sorting by oldest */
            :is(
                ytm-feed-filter-chip-bar-renderer > div,
                ytd-feed-filter-chip-bar-renderer iron-selector#chips,
                .ytChipBarViewModelHost
            ):has(.ytpa-btn.ytpa-unsupported) .ytpa-btn.ytpa-unsupported ~ .ytpa-btn {
                display: none;
            }
    
            .ytpa-random-btn-tab-fix {
                visibility: hidden;
                height: 0;
                width: 0;
            }
    
            .ytpa-button-container ~ .ytpa-button-container {
                display: none;
            }
    
            /* [2025-11] Fix for the new UI */
            .ytp-next-button.ytp-button.ytp-playlist-ui[ytpa-random="applied"] {
                border-radius: 100px !important;
                margin-left: 1em !important;
            }
        `],
        ['ytpa-buttons', /* language=css */ `
            html[dark] .ytpa-play-all-btn {
                --ytpa-playbtn-uniquecolor: #890097;
                --ytpa-playbtn-uniquecolor-hover: #b247cc;
                --ytpa-playbtn-text: white;
            }

            html[dark] .ytpa-random-btn, .ytpa-random-notice, .ytpa-random-popover {
                --ytpa-playbtn-uniquecolor: #2053b8;
                --ytpa-playbtn-uniquecolor-hover: #2b66da;
                --ytpa-playbtn-text: white;
            }

            html:not([dark]) .ytpa-play-all-btn {
                --ytpa-playbtn-uniquecolor: #fac7ff;
                --ytpa-playbtn-uniquecolor-hover: #eb8df1;
                --ytpa-playbtn-text: white;
            }

            html:not([dark]) .ytpa-random-btn, .ytpa-random-notice, .ytpa-random-popover {
                --ytpa-playbtn-uniquecolor: #bad2ff;
                --ytpa-playbtn-uniquecolor-hover: #3f60a1;
                --ytpa-playbtn-text: white;
            }

            .ytpa-play-all-btn.ytpa-unsupported {
                --ytpa-playbtn-uniquecolor: #828282 !important;
                --ytpa-playbtn-uniquecolor-hover: var(--ytpa-playbtn-uniquecolor) !important;
                --ytpa-playbtn-text: white;
            }

            .ytpa-settings-btn {
                --ytpa-playbtn-uniquecolor: var(--ytpa-bg-additive);
                --ytpa-playbtn-uniquecolor-hover: var(--ytpa-playbtn-uniquecolor);
                --ytpa-playbtn-text: var(--ytpa-fg-primary);
                display: none;
            }

            /* CLASSIC */
            ${ifUi(s.button.theme.classic)} :is(.ytpa-play-all-btn, .ytpa-random-btn > .ytpa-btn-section, .ytpa-random-notice, .ytpa-random-popover > *, .ytpa-settings-btn) {
                background-color: var(--ytpa-playbtn-uniquecolor);
                color: var(--ytpa-playbtn-text);
            }

            ${ifUi(s.button.theme.classic)} :is(.ytpa-play-all-btn, .ytpa-random-btn > .ytpa-btn-section, .ytpa-random-notice, .ytpa-random-popover > *, .ytpa-settings-btn):hover {
                background-color: var(--ytpa-playbtn-uniquecolor-hover);
            }

            /* ADAPTIVE */
            ${ifUi(s.button.theme.adaptive)} :is(.ytpa-play-all-btn, .ytpa-random-btn > .ytpa-btn-section, .ytpa-random-notice, .ytpa-random-popover > *, .ytpa-settings-btn) {
                background-color: var(--ytpa-bg-additive) !important;
                color: var(--ytpa-fg-primary) !important;
            }

            /* ADAPTIVE OUTLINE */
            ${ifUi(s.button.theme.adaptiveOutline)} :is(.ytpa-play-all-btn, .ytpa-random-btn > .ytpa-btn-section, .ytpa-random-notice, .ytpa-random-popover > *, .ytpa-settings-btn) {
                background-color: var(--ytpa-bg-additive);
                color: var(--ytpa-fg-primary);
            }

            ${ifUi(s.button.theme.adaptiveOutline)} :is(.ytpa-play-all-btn, .ytpa-random-btn) {
                --thickness: 2px;
                --translate: -2px;
                transform: translate(var(--translate), var(--translate));
                box-sizing: content-box;
                border: var(--thickness) solid var(--ytpa-playbtn-uniquecolor);
            }
            
            ${ifUi(s.settings.button.show)} .ytpa-settings-btn {
                display: flex !important;
            }
        `],
        ['ytpa-dialog', /* language=css */ `
            /*
                .ytpa-dialog
                    .ytpa-dialog-head
                        .ytpa-dialog-title
                        form
                            .ytpa-dialog-close-btn
                    .ytpa-dialog-body
            */
    
            .ytpa-dialog {
                border: none;
                border-radius: 1rem;
                background-color: var(--ytpa-bg-menu);
                color: var(--ytpa-fg-primary);
                font-size: 18px;
            }
    
            .ytpa-dialog::backdrop {
                background-color: rgba(0, 0, 0, 0.72);
            }
    
            .ytpa-dialog :is(input, button, textarea, select) {
                background-color: var(--ytpa-bg-additive);
            }
    
            .ytpa-dialog :is(input, button, select) {
                cursor: pointer;
            }
    
            .ytpa-dialog .ytpa-dialog-head {
                display: flex;
                gap: 2em;
            }
    
            .ytpa-dialog .ytpa-dialog-title {
                flex: 1;
                display: inline-block;
                font-size: 1.6em;
                border-bottom: 1px solid color-mix(in srgb, var(--ytpa-fg-primary) 30%, transparent);
                padding-bottom: 0.2em;
                margin-bottom: 0.6em;
            }
    
            .ytpa-dialog .ytpa-dialog-head form {
                display: inline-block;
            }
    
            .ytpa-dialog .ytpa-dialog-close-btn {
                border: none;
                color: var(--ytpa-fg-primary);
                font-weight: bold;
                font-size: 36px;
                width: 46px;
                height: 46px;
                text-align: center;
                display: flex;
                flex-direction: column;
                justify-content: center;
                border-radius: 0.32em;
            }
    
            .ytpa-dialog {
                width: min(100% - 2rem, 80rem);
            }
    
            @media (max-width: 400px) {
                /* TODO: there might be an actual world where i'd support mobile settings... maybe... not today though! */
                .ytpa-dialog {
                    width: 100vw;
                    height: 100vw;
                    border-radius: 0;
                }
            }
        `],
        ['ytpa-dialog-components', /* language=css */ `
            /*
                .ytpa-dialog-component-container
                    div
                        label
                        .ytpa-dialog-component
                    .ytpa-dialog-component-help
    
                OR
    
                .ytpa-dialog-component-container
                    label.ytpa-dialog-component
                        div.switch
                            input[type="checkbox"]
                            span.slider
                        div.text
                    .ytpa-dialog-component-help
            */

            .ytpa-dialog-component-container:not(:last-child) {
                margin-bottom: 1em;
            }

            .ytpa-dialog-component-container > div:has(
                input:is([type="text"], [type="password"], [type="number"]),
                textarea,
                select
            ) > label {
                display: block;
                margin-bottom: 0.2em;
                font-size: 18px;
            }

            .ytpa-dialog-component-container :is(
                input:is([type="text"], [type="password"], [type="number"]),
                textarea,
                select
            ) {
                color: var(--ytpa-fg-primary);
                border-radius: 0.48rem;
                background-color: var(--ytpa-bg-additive-heavy);
                border: 2px solid var(--ytpa-bg-additive-inverse-heavy);
                padding: 0.24em;
                font-size: 16px;
            }
            
            .ytpa-dialog-component-container select option {
                color: var(--ytpa-fg-primary);
                background-color: var(--ytpa-bg-additive-heavy);
            }

            .ytpa-dialog-component-container textarea {
                min-width: 50%;
                min-height: 72px;
                max-width: 100%;
            }

            .ytpa-dialog-component-container {
                display: flex;
                flex-direction: column;
            }

            .ytpa-dialog-component-help {
                background-color: var(--ytpa-bg-additive-inverse-heavy);
                border: 2px solid var(--ytpa-bg-additive-heavy);
                padding: 0.32em;
                border-radius: 0.48rem;
                white-space: pre;
                margin-top: 0.32em;
                font-size: 18px;
            }

            label.ytpa-dialog-component:has(.switch) {
                display: flex;
            }

            label.ytpa-dialog-component:has(.switch) .text {
                display: flex;
                flex-direction: column;
                justify-content: center;
                margin-left: 1rem;
            }

            .ytpa-dialog-component .switch {
                display: inline-flex;
                align-items: center;
                cursor: pointer;
                user-select: none;
            }

            .ytpa-dialog-component .switch input {
                position: absolute;
                opacity: 0;
                width: 1px;
                height: 1px;
            }

            .ytpa-dialog-component .switch .slider {
                width: 46px;
                height: 24px;
                border-radius: 999px;
                background: #a1a1a1;
                position: relative;
                transition: background .2s ease;
                flex: 0 0 auto;
            }

            .ytpa-dialog-component .switch .slider::before {
                content: "";
                position: absolute;
                top: 4px;
                left: 4px;
                width: 16px;
                height: 16px;
                border-radius: 50%;
                background: white;
                transition: transform .2s ease;
                box-shadow: 0 2px 4px rgba(0, 0, 0, .2);
            }

            .ytpa-dialog-component .switch input:checked + .slider {
                background: #4caf50;
            }

            .ytpa-dialog-component .switch input:checked + .slider::before {
                transform: translateX(22px);
            }

            .ytpa-dialog-component .switch input:focus-visible + .slider {
                outline: 3px solid var(--ytpa-bg-additive-heavy);
                outline-offset: 2px;
            }
        `],
        ['ytpa-spacer', /* language=css */ `
            html {
                --ytpa-btn-spacer-neigbor-margin-left: 0.2em;
            }
            
            ${ifUi(s.spacer.show)} {
                --ytpa-btn-spacer-neigbor-margin-left: 0.6em;
            }
            
            .ytpa-btn-spacer {
                display: none;
            }

            .ytpa-btn-spacer + .ytpa-btn {
                margin-left: var(--ytpa-btn-spacer-neigbor-margin-left);
            }

            ${ifUi(s.spacer.show)} .ytpa-btn-spacer {
                display: inline-block;
                background-color: color-mix(in srgb, var(--ytpa-bg-additive) 64%, transparent);
                width: 8px;
                height: calc(var(--ytpa-btn-height) - 0.4rem);
                transform: translateY(0.2rem);
                border-radius: 1rem;
            }
            
            .ytpa-btn-spacer ~ .ytpa-btn-spacer {
                display: none !important;
            }
        `],
    ];
}))((() => {
    // -- scriptGlobals --

    // where the things live that are needed everywhere, except for the outside world

    const settings = {
        ui: {
            button: {
                theme: {
                    classic: 'button-theme-classic',
                    adaptive: 'button-theme-adaptive',
                    adaptiveOutline: 'button-theme-adaptive-outline',
                },
            },
            spacer: {
                show: 'spacer-show'
            },
            settings: {
                button: {
                    show: 'settings-button-show',
                },
            },
        },
    };

    return {
        s: settings,
    };
})());

/**
 * @var {'userscript'|'extension'} _environment_
 */
/**
 * @var {{}} globalThis
 */
/**
 * @var {{}} unsafeWindow
 */
/**
 * @var {{
 *  xmlHttpRequest: (config: object) => void,
 *  getValue: (key: string, defaultValue?: any) => Promise<any>,
 *  setValue: (key: string, value: any) => Promise<void>,
 *  deleteValue: (key: string) => Promise<void>,
 *  listValues: () => Promise<string[]>,
 *  info: {
 *      script: {
 *          version: string,
 *      },
 *  },
 * }} GM
 */
/**
 * @var {{ userAgentData: any }&Navigator} navigator
 */
/**
 * @template K
 *
 * @typedef {
 *  (
 *      event: K,
 *      handler: (event: GlobalEventHandlersEventMap[K]) => any
 *  ) => WrappedElementBuilder
 * } WrappedElementBuilderOnHandler
 */
/**
 * @typedef {Object} WrappedElementBuilder
 * @property {() => HTMLElement} build
 * @property {() => Promise<HTMLElement>} buildWithSync
 * @property {(fn: (element: HTMLElement) => void) => WrappedElementBuilder} onBuild
 * @property {(...append: Array<Node|string>) => WrappedElementBuilder} onBuildAppend
 * @property {(text: string) => WrappedElementBuilder} onBuildText
 * @property {WrappedElementBuilderOnHandler<keyof GlobalEventHandlersEventMap>} on
 * @property {(value: string) => WrappedElementBuilder} id
 * @property {(value: string) => WrappedElementBuilder} className
 * @property {(value: string) => WrappedElementBuilder} addClass
 * @property {(value: string) => WrappedElementBuilder} name
 * @property {(value: string) => WrappedElementBuilder} href
 * @property {(value: string) => WrappedElementBuilder} target
 * @property {(value: string) => WrappedElementBuilder} rel
 * @property {(value: string) => WrappedElementBuilder} role
 * @property {(value: string) => WrappedElementBuilder} tabindex
 * @property {(value: string) => WrappedElementBuilder} hidden
 * @property {(value: string) => WrappedElementBuilder} style
 * @property {(value: string) => WrappedElementBuilder} type
 * @property {(value: string) => WrappedElementBuilder} method
 * @property {(value: string) => WrappedElementBuilder} value
 * @property {(value: string) => WrappedElementBuilder} checked
 * @property {(value: string) => WrappedElementBuilder} selected
 * @property {(value: string) => WrappedElementBuilder} for
 * @property {(value: string) => WrappedElementBuilder} aria_label
 * @property {(value: string) => WrappedElementBuilder} aria_haspopup
 * @property {(value: string) => WrappedElementBuilder} aria_expanded
 * @property {(value: string) => WrappedElementBuilder} aria_hidden
 * @property {(value: string) => WrappedElementBuilder} aria_describedby
 * @property {(value: string) => WrappedElementBuilder} data_list
 * @property {(value: string) => WrappedElementBuilder} data_index
 */
// BEWARE, THE BELOW JSDOC IS NOT FOR THE FAINT OF HEART
// This is not unhinged, this isn't even overhinged, we have arrived at extrahinged.
/** @typedef {Record<string, any>} HookBag */
/**
 * @template T
 * @template {any[]} TParams
 *
 * @typedef {(...args: TParams) => T} _Component_Dsl
 */
/** @typedef {any[] | {[key: any]: any}} _Component_Dsl_Param_ArrayObject */
/**
 * @template T
 *
 * @typedef {ComponentWithHookHelp<T> | ComponentWithHookConfirm<T>} Hooks
 */
// HOOKS
/**
 * @template T
 *
 * @typedef {{ withTest: (test: string) => T, hooked: { test: string }}} ComponentWithHookTest
 */
/**
 * @template T
 *
 * @typedef {{ withHelp: (help: string) => T, hooked: { help: string } }} ComponentWithHookHelp
 */
/**
 * @template T
 *
 * @typedef {{ withConfirm: (confirm: string) => T, hooked: { confirm: string } }} ComponentWithHookConfirm
 */
// VALUES
/**
 * @template {string} M
 *
 * @typedef {{ of: any, value: any, value: any, m: Record<M, any>, hooked: HookBag }} ComponentBase
 */
/** @typedef {ComponentBase<'dummy'> & Hooks<ComponentDummy>} ComponentDummy */
/** @typedef {ComponentBase<'text'> & Hooks<ComponentText>} ComponentText */
/** @typedef {ComponentBase<'textarea'> & Hooks<ComponentTextarea>} ComponentTextarea */
/** @typedef {ComponentBase<'password'> & Hooks<ComponentPassword>} ComponentPassword */
/** @typedef {ComponentBase<'number'> & Hooks<ComponentNumber>} ComponentNumber */
/** @typedef {ComponentBase<'toggle'> & Hooks<ComponentToggle>} ComponentToggle */
/** @typedef {ComponentBase<'oneOf'> & Hooks<ComponentOneOf>} ComponentOneOf */
/** @typedef {ComponentBase<'anyOf'> & Hooks<ComponentAnyOf>} ComponentAnyOf */
/**
 * @typedef {
 *  {}
 *  | ComponentDummy
 *  | ComponentText
 *  | ComponentTextarea
 *  | ComponentPassword
 *  | ComponentNumber
 *  | ComponentToggle
 *  | ComponentAnyOf
 *  | ComponentOneOf
 * } ComponentT
 */
/**
 * @typedef {{ map: Map<{ name: string, displayText: string }, ComponentT> }} ValueDialogComponents
 */
/**
 * @typedef {{
 *  general: {
 *      ui: {
 *          buttonTheme: string,
 *          spacerVisible: boolean,
 *          settingsButtonVisible: boolean,
 *      },
 *  },
 * }} SettingsData
 *
 * @typedef {{
 *  version: number,
 *  data: SettingsData,
 * }} Settings
 */