// ==UserScript== // @name YouTube Play All // @description Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel // @version 20260523-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 (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 { Fmt, TrustedTypes: { policy }, Icons, HtmlCreation: { $builder, $style }, Console: console, Safety: { handleError, attachSafetyListener, safeTimeout, safeInterval, safeEventListener }, Versioned, Greeter, Reset, 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(policy.createScript(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; } const settingsData = SettingsStorage.data(); // --- actual code --- const ytpaBtnSelectedAttribute = 'data-ytpa-button-selection-detection-selected'; 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 = ''; const idPerVideoCache = {}; // 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 = () => { return /UC[\w-]+/.test(channelId); } const tryFetch = async () => { const href = document.querySelector('#content ytd-rich-item-renderer a, .rich-grid-renderer-contents a.YtmCompactMediaItemImage')?.href; // prevent network spam if (href in idPerVideoCache) { channelId = idPerVideoCache[href]; return; } try { // [20260523-0] \x22 is necessary for mobile because for some reason there the JSON is serliazed into a string, yep const html = await (await fetch(href)).text(); idPerVideoCache[href] = channelId = // #77 added multiple attempts at gathering the actual channelId without accidentally matching unrelated channels // primarily expect channelId with greedy matching space after `"subscribeButton"` /var ytInitialData.+?(?:"|'|\\x22)subscribeButton(?:"|'|\\x22):.*?(?:"|'|\\x22)channelId(?:"|'|\\x22):(?:"|'|\\x22)(UC[\w-]+)(?:"|'|\\x22)/.exec(html)?.[1] // if structure changes, still prioritize channelId following `Subscribe` ?? /var ytInitialData.+?[Ss]ubscribe.*?(?:"|'|\\x22)channelId(?:"|'|\\x22):(?:"|'|\\x22)(UC[\w-]+)(?:"|'|\\x22)/.exec(html)?.[1] // when all things fail, use the old attempt to match any channelId, can cause false links (see #77) ?? /var ytInitialData.+?(?:"|'|\\x22)channelId(?:"|'|\\x22):(?:"|'|\\x22)(UC[\w-]+)(?:"|'|\\x22)/.exec(html)?.[1] // otherwise use less reliable fallbacks below ?? ''; } 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(' { const verb = settingsData.general.features.showPlaylistInsteadOfPlaying ? 'View' : 'Play'; const defaultText = [`${verb} All`, `${verb} Popular`]; const match = window.location.pathname.match(/^\/[^/]+\/(.+)/); if (!match) { throw 'Trying to access play all outside of channel content tabs.'; } switch (match[1]) { case 'videos': if (settingsData.general.features.playEverythingInsteadOfVideos) { return [['UU', null], [`${verb} All Content`, defaultText[1]]]; } return [['UULF', 'UULP'], defaultText]; case 'shorts': return [['UUSH', 'UUPS'], defaultText]; case 'streams': return [['UULV', 'UUPV'], defaultText]; } throw 'Could not detect what content to play.'; })(); const buildPlayAllUrl = playlist => { if (playlist === null) { return 'javascript:void()'; } if (settingsData.general.features.showPlaylistInsteadOfPlaying) { return `/playlist?list=${playlist}${id}`; } return `/playlist?list=${playlist}${id}&playnext=1`; }; 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(buildPlayAllUrl(allPlaylist)) .onBuildAppend(allText) .build(), ); } else 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(buildPlayAllUrl(popularPlaylist)) .onBuildAppend(popularText) .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(Icons.settings) .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; const buttonsToCheck = document.querySelector(':is(ytd-browse, ytm-browse):not([hidden]) .ytChipBarViewModelChipBarScrollContainer')?.children; if (buttonsToCheck && !buttonsToCheck[0].querySelector('button[role="combobox"]')) { const selectedButtonIndex = Array.from(buttonsToCheck)?.map(child => child.hasAttribute(ytpaBtnSelectedAttribute) || !!child.querySelector('[aria-selected="true"]'))?.indexOf(true); if (selectedButtonIndex !== null && selectedButtonIndex !== -1) { currentSelection = selectedButtonIndex + 1; } } // Regenerate button if switched between Latest and Popular // Mobile needs custom click listeners as mutation observers proved to be unreliable in that UI. if (location.host !== 'm.youtube.com') { 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(); }; Reset.subscribe(() => { removeButton(); addButton(); }); // 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); // [2026-03-22] YouTube does not seem to consistently fire navigation. // This also is an attempt against race conditions. // The performance impact is negligible due to early returns. safeInterval(addButton, 3000); } // 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. 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 getPlaylistContainer = () => document.querySelector('#secondary ytd-playlist-panel-renderer, #below ytd-playlist-panel-renderer'); const getStorageKey = () => `ytpa-random-${urlParams.get('list')}`; const updateStorage = () => { const elements = getPlaylistContainer().querySelectorAll('a#wc-endpoint:not([href*="&ytpa-random="])'); if (elements.length === 0) { return; } elements.forEach(element => { const videoId = (new URLSearchParams(new URL(element.href).searchParams)).get('v'); if (!isWatched(videoId)) { localStorage.setItem(getStorageKey(), JSON.stringify({ ...getStorage(true), [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', ''); } }); }; const getStorage = (allowEmpty = false) => { const value = localStorage.getItem(getStorageKey()); if (!value) { if (allowEmpty) { return {}; } updateStorage(); return getStorage(true); } return JSON.parse(value); } const isWatched = videoId => getStorage(true)[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 = getPlaylistContainer(); if (playlistContainer === null) { return; } if (playlistContainer.hasAttribute('ytpa-random')) { return; } playlistContainer.setAttribute('ytpa-random', 'applied'); playlistContainer.querySelector('#items').insertAdjacentElement( 'beforebegin', $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(), ); // ensure all the links are "corrected" to random play safeInterval(updateStorage, 1000); // has to be an interval because YouTube keeps erasing the badge safeInterval(() => { if (playlistContainer.querySelector('.ytpa-badge.ytpa-random-badge')) { return; } const header = playlistContainer.querySelector('h3 a'); header.href = 'javascript:none'; header.insertAdjacentElement( 'beforeend', $builder('span.ytpa-badge.ytpa-random-badge') .on('click', event => { event.preventDefault(); localStorage.removeItem(getStorageKey()); const params = new URLSearchParams(location.search); params.delete('ytpa-random'); window.location.href = `${window.location.pathname}?${params.toString()}`; }) .onBuildAppend( ytpaRandom, $builder('span') .style('font-size: 2rem; vertical-align: top') .onBuildText('×') .build(), ) .build(), ); }, 5000); if (urlParams.get('ytpa-random-initial') === '1' || isWatched(getVideoId(location.href))) { playNextRandom(); } 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 newIdString = () => `ytpa-${ns}-${newUuid()}`; return { newIdString, }; }; 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); }; /** * @template {{}} T * @template {string} K * * @typedef {{ [K]: Map, set: (key: string, value: value) => T & Settable}} Settable * * @param {T} object * @param {K} key * @return {T & Settable} */ const asSettable = (object, key = 'map') => { object[key] = new Map(); object.set = (k, v) => { object[key].set(k, v); return object; }; return object; }; return { isObject, merge, watch, asSettable, }; })() 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 TrustedTypes = (() => { const { pass } = ControlFlow; // yes, kind of what TT was supposed to prevent, but I control the input, so... // TODO: maybe implement actual sanitization? sounds like wasted effort const policy = ( typeof trustedTypes !== 'undefined' ? trustedTypes : null )?.createPolicy?.('userscript', { createHTML: pass, createScript: pass, }) ?? new Proxy({}, { get() { return pass; }, }); return { policy, }; })(); const Icons = (() => { const { policy } = TrustedTypes; const attachFunctions = icon => { const element = icon.cloneNode(true); element.withHeight = height => { element.style.height = height; return element; }; element.withVerticalAlign = verticalAlign => { element.style.verticalAlign = verticalAlign; return element; }; element.aligned = () => element.withHeight('1em').withVerticalAlign('-0.145em'); return element; }; /** * @param {string} code * @return {Icon} */ const create = code => attachFunctions(document.importNode(new DOMParser().parseFromString(policy.createHTML(code), 'image/svg+xml').documentElement, true)); return { settings: create(` `), warning: create(` `), }; })(); const HtmlCreation = (() => { /** * Safely dynamically create HTML-Elements. * * Creates actual DOM Elements, not plain HTML. * * Example: * $builder('div') * .id('foo') * .className('a b c') * .onBuildText('Test') * .build() * * Result: * ``` *
* <b>Test</b> *
; * ``` * * @return WrappedElementBuilder */ const $builder = query => { /** * @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), // eslint-disable-next-line log: createLogger(console.log, 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(':is(ytd-browse, ytm-browse):not([hidden]) chip-bar-view-model.ytChipBarViewModelHost button[role="combobox"]'); if (dropdownButton) { dropdownButton.addEventListener('click', () => { waitForElement('tp-yt-iron-dropdown.style-scope.ytd-popup-container:not([hidden], [style*="display: none"]) yt-sheet-view-model, bottom-sheet-container[role="dialog"]:not([hidden]) yt-sheet-view-model') .then(element => resolve(element.querySelectorAll('yt-list-item-view-model'))) }); } else { resolve(document.querySelectorAll(':is(ytd-browse, ytm-browse):not([hidden]) chip-bar-view-model.ytChipBarViewModelHost div.ytChipBarViewModelChipWrapper button')); } }), }; 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 ofGroup = () => Obj.asSettable({ m: { group: _ } }); const collection = () => Obj.asSettable({}); const key = (name, displayText) => ({ name, displayText }); return { ofInitial, ofGroup, 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); const has = x => x !== undefined; /** * @param {string} name * @param {string} displayText * @param {ComponentT} component * @return {HTMLElement} */ const createElementByComponent = (name, displayText, component) => $builder(`div.${baseClassName}-container`).onBuild(container => { const id = IdNamespace.newIdString(); 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) .onBuild(init) .build(); /** @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') )); 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; }, ) ); } if (typeof result === 'boolean') { throw 'Could not build ValuesDialog component.'; } container.append(result); if (has(component.hooked.help)) { container.append( $builder(`details`) .id(helpId) .className(`${baseClassName}-help`) .onBuildText(component.hooked.help) .build(), ); result.setAttribute('aria-describedby', helpId); } }).build(); /** * @param {ComponentGroup | Components} components * @return {HTMLElement[]} */ const createElements = components => components.map.entries().toArray().map( /** * @param {string} name * @param {string} displayText * @param {ComponentT} component * @return {HTMLElement} */ ([{ name, displayText }, component]) => { if (has(component.m.group)) { return $builder(`div.${baseClassName}-group`) .onBuildAppend( $builder(`div.${baseClassName}-group-title`) .onBuildText(displayText) .build(), ...createElements(component) ) .build(); } return createElementByComponent(name, displayText, component); }, ); /** * @param {ComponentT} component * @return {any} */ const getValue = component => { if (has(component.m.group)) { return Object.fromEntries(component.map.entries().toArray().map(([k, v]) => [k.name, getValue(v)])); } return component.value; }; /** * @param {Components} components * @return {Promise<{ [key: string]: any }>} */ const show = async (components) => newDialog() .with('YTPA Settings', push => { createElements(components).forEach(push); }) .then(() => Object.fromEntries( components.map.entries().map(([k, v]) => [k.name, getValue(v)]), )); return { show }; })(); // solves the slight "circular" dependency with a promise let setSettingsStorage = () => { throw 'Premature call to setSettingsStorage.' }; /** @type {Promise} */ 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, }, }, }, })), M('20260223-0', previous => Obj.merge(previous, { data: { general: { features: { // Both added due to demand and low implementation effort. // See: https://github.com/RobertWesner/YouTube-Play-All/issues/52 showPlaylistInsteadOfPlaying: false, playEverythingInsteadOfVideos: false, }, }, }, })), ]; 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); window.location.reload(); }; await load(); const exports = { data, clear, }; setSettingsStorage(exports); return exports; })(); const SettingsDialog = (async () => { const Component = ValuesDialogComponent; const console = Console; /** @var {SettingsData} */ const data = (await settingsStoragePromise).data(); const components = Component .collection() .set(Component.key('appearance', 'Appearance'), Component .ofGroup() .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!'), ) ) .set(Component.key('functionality', 'Functionality'), Component .ofGroup() .set(Component.key('showPlaylistInsteadOfPlaying', 'Show playlist instead of playing it'), Component .ofInitial(data.general.features.showPlaylistInsteadOfPlaying) .asToggle() ) .set(Component.key('playEverythingInsteadOfVideos', 'Play all content instead of just videos'), Component .ofInitial(data.general.features.playEverythingInsteadOfVideos) .asToggle() .withHelp(Fmt.trimIndent(` Only applies to the playlist used on "Videos" when sorting by "Latest". `)) ) ) ; const show = () => { ValuesDialog.show(components).then(values => { const { appearance: { buttonTheme, spacerVisible, settingsButtonVisible, }, functionality: { showPlaylistInsteadOfPlaying, playEverythingInsteadOfVideos, }, } = 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; data.general.features.showPlaylistInsteadOfPlaying = showPlaylistInsteadOfPlaying; data.general.features.playEverythingInsteadOfVideos = playEverythingInsteadOfVideos; SettingsHandlers.updateByStorageData(); Reset.reset(); }); }; return { show, }; })(); const Reset = (() => { const listeners = new Set(); const reset = () => [...listeners].forEach(listener => listener()); /** * @param {function} listener * @return {() => void} */ const subscribe = listener => { listeners.add(listener); return () => listeners.delete(listener); }; return { reset, subscribe, }; })(); 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, TrustedTypes, Icons, HtmlCreation, Console, Safety, AsyncOperations, Versioned, Greeter, Dialog, ValuesDialogComponent, ValuesDialog, SettingsHandlers, Reset, Debug, }, { SettingsStorage, SettingsDialog, }]; }, () => { const s = G.s.ui; const ifUi = setting => `html[ytpa-ui-setting~="${setting}"]`; const dark = '[dark], [darker-dark-theme]'; return [ ['ytpa-btn-height', ''], ['ytpa-base', /* language=css */ ` html { --ytpa---base-1: rgba(255, 255, 255, 0.064); --ytpa---base-2: rgba(0, 0, 0, 0.128); } html:is(${dark}) { --ytpa-bg-base: var(--yt-sys-color-baseline--base-background, var(--yt-spec-base-background, #0f0f0f)); --ytpa-bg-raised: var(--yt-sys-color-baseline--raised-background, var(--yt-spec-raised-background, #212121)); --ytpa-bg-menu: var(--yt-sys-color-baseline--menu-background, var(--yt-spec-menu-background, #282828)); --ytpa-bg-additive: var(--yt-sys-color-baseline--additive-background, var(--yt-spec-additive-background, rgba(255, 255, 255, 0.1))); --ytpa-bg-additive-inverse: var(--yt-sys-color-baseline--additive-background-inverse, var(--yt-spec-additive-background-inverse, rgba(0, 0, 0, 0.05))); --ytpa-fg-primary: var(--yt-sys-color-baseline--text-primary, var(--yt-spec-text-primary, #f1f1f1)); --ytpa-fg-secondary: var(--yt-sys-color-baseline--text-secondary, var(--yt-spec-text-secondary, #aaa)); --ytpa-fg-disabled: var(--yt-sys-color-baseline--text-disabled, var(--yt-spec-text-disabled, #717171)); --ytpa-cta: var(--yt-sys-color-baseline--call-to-action, var(--yt-spec-call-to-action, #3ea6ff)); --ytpa-bg-additive-heavy: var(--ytpa---base-2); --ytpa-bg-additive-inverse-heavy: var(--ytpa---base-1); } html:not(:is(${dark})) { --ytpa-bg-base: var(--yt-sys-color-baseline--base-background, var(--yt-spec-base-background, #fff)); --ytpa-bg-raised: var(--yt-sys-color-baseline--raised-background, var(--yt-spec-raised-background, #fff)); --ytpa-bg-menu: var(--yt-sys-color-baseline--menu-background, var(--yt-spec-menu-background, #fff)); --ytpa-bg-additive: var(--yt-sys-color-baseline--additive-background, var(--yt-spec-additive-background, rgba(0, 0, 0, 0.05))); --ytpa-bg-additive-inverse: var(--yt-sys-color-baseline--additive-background-inverse, var(--yt-spec-additive-background-inverse, rgba(255, 255, 255, 0.1))); --ytpa-fg-primary: var(--yt-sys-color-baseline--text-primary, var(--yt-spec-text-primary, #0f0f0f)); --ytpa-fg-secondary: var(--yt-sys-color-baseline--text-secondary, var(--yt-spec-text-secondary, #606060)); --ytpa-fg-disabled: var(--yt-sys-color-baseline--text-disabled, var(--yt-spec-text-disabled, #909090)); --ytpa-cta: var(--yt-sys-color-baseline--call-to-action, var(--yt-spec-call-to-action, #065fd4)); --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); white-space: nowrap; } .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-badge { border-radius: 8px; padding: 0.2em; font-size: 0.8em; vertical-align: top; } .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; } /* Fix themes with button outlines on mobile */ ytm-feed-filter-chip-bar-renderer#filter-chip-bar > .chip-bar-contents.chip-bar-no-vertical-padding { overflow: visible; } `], ['ytpa-buttons', /* language=css */ ` html:is(${dark}) .ytpa-play-all-btn { --ytpa-playbtn-uniquecolor: #890097; --ytpa-playbtn-uniquecolor-hover: #b247cc; --ytpa-playbtn-text: white; } html:is(${dark}) :is(.ytpa-random-btn, .ytpa-random-badge, .ytpa-random-notice, .ytpa-random-popover) { --ytpa-playbtn-uniquecolor: #2053b8; --ytpa-playbtn-uniquecolor-hover: #2b66da; --ytpa-playbtn-text: white; } html:not(:is(${dark})) .ytpa-play-all-btn { --ytpa-playbtn-uniquecolor: #fac7ff; --ytpa-playbtn-uniquecolor-hover: #eb8df1; --ytpa-playbtn-text: white; } html:not(:is(${dark})) :is(.ytpa-random-btn, .ytpa-random-badge, .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-badge, .ytpa-random-notice, .ytpa-random-popover > *, .ytpa-random-popover > a:visited, .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-badge, .ytpa-random-notice, .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-badge, .ytpa-random-notice, .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-badge, .ytpa-random-notice, .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, .ytpa-random-badge, .ytpa-random-notice) { --thickness: 2px; --translate: -2px; transform: translate(var(--translate), var(--translate)); box-sizing: content-box; border: var(--thickness) solid var(--ytpa-playbtn-uniquecolor); } /* Multiple */ :is(${ifUi(s.button.theme.adaptive)}, ${ifUi(s.button.theme.adaptiveOutline)}) .ytpa-random-popover { background-color: var(--ytpa-bg-base); color: var(--ytpa-fg-primary); } :is(${ifUi(s.button.theme.adaptive)}, ${ifUi(s.button.theme.adaptiveOutline)}) :is(.ytpa-random-popover > *, .ytpa-random-popover > a:visited){ background-color: var(--ytpa-bg-additive); color: var(--ytpa-fg-primary); } :is(${ifUi(s.button.theme.adaptive)}, ${ifUi(s.button.theme.adaptiveOutline)}) .ytpa-random-notice { margin-left: 2px; width: 100%; box-sizing: border-box; } ${ifUi(s.settings.button.show)} .ytpa-settings-btn { display: flex !important; } .ytpa-random-badge { margin-left: 0.4em; } .ytpa-random-badge > span { margin-left: 0.32em; vertical-align: middle; } `], ['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 OR .ytpa-dialog-component-group .ytpa-dialog-component-group-title {any of the above} */ .ytpa-dialog-component-container:not(:last-child), .ytpa-dialog-component-group, .ytpa-dialog-component-group-title { 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-dialog-component-group .ytpa-dialog-component-group-title { font-size: 1.2em; font-weight: bold; text-transform: uppercase; } .ytpa-dialog-component-group > :not(.ytpa-dialog-component-group-title) { margin-left: 0.6rem; } `], ['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; } ${ifUi(s.spacer.show)} ytm-rich-grid-renderer .ytpa-btn-spacer { margin-left: 0.5em; } `], ]; }))((() => { // -- scriptGlobals -- // where the things live that are needed everywhere, except for the outside world // TODO: only really relevant to ifUi() for now, might be worth renaming, since all feature settings dont need a flat string representation 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, * setValue: (key: string, value: any) => Promise, * deleteValue: (key: string) => Promise, * listValues: () => Promise, * info: { * script: { * version: string, * }, * }, * }} GM */ /** * @var {{ userAgentData: any }&Navigator} navigator */ /** * @template K * * @typedef { * ( * event: K, * handler: (event: GlobalEventHandlersEventMap[K]) => any * ) => WrappedElementBuilder * } WrappedElementBuilderOnHandler */ /** * @typedef {Object} WrappedElementBuilderBase * @property {() => HTMLElement} build * @property {() => Promise} buildWithSync * @property {(fn: (element: HTMLElement) => void) => WrappedElementBuilder} onBuild * @property {(...append: Array) => WrappedElementBuilder} onBuildAppend * @property {(text: string) => WrappedElementBuilder} onBuildText * @property {WrappedElementBuilderOnHandler} 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} title * @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 */ /** * With fallback for undocumented calls like all dynamic data_*(). * * @typedef {WrappedElementBuilderBase | Record WrappedElementBuilder>} WrappedElementBuilder */ // 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} 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 | ComponentWithHookConfirm} 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, hooked: HookBag }} ComponentBase */ /** @typedef {{ name: string, displayText: string }} ComponentKey */ /** @typedef {{ m: { group: any } } & Components} ComponentGroup */ /** @typedef {ComponentBase<'dummy'> & Hooks} ComponentDummy */ /** @typedef {ComponentBase<'text'> & Hooks} ComponentText */ /** @typedef {ComponentBase<'textarea'> & Hooks} ComponentTextarea */ /** @typedef {ComponentBase<'password'> & Hooks} ComponentPassword */ /** @typedef {ComponentBase<'number'> & Hooks} ComponentNumber */ /** @typedef {ComponentBase<'toggle'> & Hooks} ComponentToggle */ /** @typedef {ComponentBase<'oneOf'> & Hooks} ComponentOneOf */ /** @typedef {ComponentBase<'anyOf'> & Hooks} ComponentAnyOf */ /** * @typedef { * {} * | ComponentGroup * | ComponentDummy * | ComponentText * | ComponentTextarea * | ComponentPassword * | ComponentNumber * | ComponentToggle * | ComponentAnyOf * | ComponentOneOf * } ComponentT */ /** * @typedef {{ map: Map }} Components */ /** * @typedef {{ * general: { * ui: { * buttonTheme: string, * spacerVisible: boolean, * settingsButtonVisible: boolean, * }, * features: { * showPlaylistInsteadOfPlaying: boolean, * playEverythingInsteadOfVideos: boolean, * }, * }, * }} SettingsData * * @typedef {{ * version: number, * data: SettingsData, * }} Settings */ /** * @typedef {{ * get: () => SettingsData, * set: (k: string, v: any) => Promise, * clear: () => Promise, * }} SettingsStorage */ /** * @typedef {HTMLElement & { * withHeight: (height: string) => Icon, * withVerticalAlign: (verticalAlign: string) => Icon, * aligned: () => Icon, * }} Icon */