// ==UserScript== // @name YouTube Play All // @description Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel // @version 20250325-2 // @author Robert Wesner (https://robert.wesner.io) // @license MIT // @namespace http://robert.wesner.io/ // @match https://*.youtube.com/* // @icon https://scripts.yt/favicon.ico // @grant none // @downloadURL none // ==/UserScript== /** * @var {{ defaultPolicy: any, createPolicy: (string, Object) => void }} window.trustedTypes */ /** * @var {{ script: { version: string } }} GM_info */ (async function () { 'use strict'; const scriptVersion = GM_info.script.version || null; if (scriptVersion && /-(alpha|beta|dev|test)$/.test(scriptVersion)) { console.log( '%cYTPA - YouTube Play All\n', 'color: #bf4bcc; font-size: 32px; font-weight: bold', 'You are currently running a test version:', scriptVersion, ); } if (window.hasOwnProperty('trustedTypes') && !window.trustedTypes.defaultPolicy) { window.trustedTypes.createPolicy('default', { createHTML: string => string }); } document.head.insertAdjacentHTML('beforeend', ``); let id; const apply = () => { let parent = location.host === 'm.youtube.com' // mobile view ? document.querySelector('ytm-feed-filter-chip-bar-renderer > div') // desktop view : document.querySelector('ytd-feed-filter-chip-bar-renderer iron-selector#chips'); // #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'); grid.insertAdjacentHTML('afterbegin', '
'); parent = grid.querySelector('.ytpa-button-container'); } // See: available-lists.md let [allPlaylist, popularPlaylist] = window.location.pathname.endsWith('/videos') // Normal videos // list=UULP has the all videos sorted by popular // list=UU adds shorts into the playlist, list=UULF 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 (parent.querySelector(':nth-child(2).selected, :nth-child(2).iron-selected')) { parent.insertAdjacentHTML( 'beforeend', `Play Popular` ); } else { parent.insertAdjacentHTML( 'beforeend', `Play All` ); } 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 => btn.addEventListener('click', event => { event.preventDefault(); window.location.href = btn.href; })); } else { // Only allow random play in desktop version for now parent.insertAdjacentHTML('beforeend', ` Play Random `); document.body.insertAdjacentHTML('beforeend', ` `); const randomMoreOptionsBtn = document.querySelector('.ytpa-random-more-options-btn'); const randomPopover = document.querySelector('.ytpa-random-popover'); randomMoreOptionsBtn.addEventListener('click', () => { const rect = randomMoreOptionsBtn.getBoundingClientRect(); randomPopover.style.top = rect.bottom.toString() + 'px'; randomPopover.style.left = rect.right.toString() + 'px'; randomPopover.removeAttribute('hidden'); }); randomPopover.addEventListener('mouseleave', () => { randomPopover.setAttribute('hidden', ''); }); } }; const observer = new MutationObserver(apply); const addButton = async () => { observer.disconnect(); if (!(window.location.pathname.endsWith('/videos') || window.location.pathname.endsWith('/shorts') || window.location.pathname.endsWith('/streams'))) { return; } // This check is necessary for the mobile Interval if (document.querySelector('.ytpa-play-all-btn')) { return; } const html = await (await fetch('.')).text(); const i = html.indexOf(' 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 setInterval(addButton, 1000); } else { window.addEventListener('yt-navigate-start', removeButton); window.addEventListener('yt-navigate-finish', addButton); } // 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 getVideoId = url => new URLSearchParams(new URL(url).search).get('v'); 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*=zsA3X40nz9w]').forEach( element => element.parentElement.setAttribute('hidden', ''), ); }; /** * @return {{ getProgressState: () => { current: number, duration, number }, pauseVideo: () => void, isLifaAdPlaying: () => boolean }} player */ const getPlayer = () => document.querySelector('#movie_player'); const isAdPlaying = () => !!document.querySelector('.ad-interrupting'); // Storage needs to now be { [videoId]: bool } try { if (Array.isArray(getStorage())) { localStorage.removeItem(getStorageKey()); } } catch (e) { localStorage.removeItem(getStorageKey()); } const playNextRandom = (reload = false) => { 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.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 = videos.length - 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 { 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'); if (playlistContainer === null) { return; } if (playlistContainer.hasAttribute('ytpa-random')) { return; } playlistContainer.setAttribute('ytpa-random', 'applied'); playlistContainer.querySelector('.header').insertAdjacentHTML('afterend', `
This playlist is using random play.
The videos will not be played in the order listed here.
`) const storage = getStorage(); playlistContainer.querySelectorAll('#wc-endpoint').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 element.addEventListener('click', event => { event.preventDefault(); window.location.href = element.href; }); const entryKey= getVideoId(element.href); if (isWatched(entryKey)) { element.parentElement.setAttribute('hidden', ''); } }); localStorage.setItem(getStorageKey(), JSON.stringify(storage)); if (urlParams.get('ytpa-random-initial') === '1' || isWatched(getVideoId(location.href))) { playNextRandom(); return; } const header = playlistContainer.querySelector('h3 a'); header.innerHTML += ` ${ytpaRandom} ×`; header.href = 'javascript:none'; header.querySelector('.ytpa-random-badge').addEventListener('click', event => { event.preventDefault(); localStorage.removeItem(getStorageKey()); let params = new URLSearchParams(location.search); params.delete('ytpa-random'); window.location.href = `${window.location.pathname}?${params.toString()}`; }); document.addEventListener('keydown', event => { // SHIFT + N if (event.shiftKey && event.key.toLowerCase() === 'n') { 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); } }); if (isIntervalSet) { return; } isIntervalSet = true; setInterval(() => { const videoId = getVideoId(location.href); let 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 - 2) { // make sure vanilla autoplay doesnt take over player.pauseVideo(); 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 = document.createElement('span'); newButton.className = nextButton.className; newButton.innerHTML = nextButton.innerHTML; nextButton.replaceWith(newButton); newButton.setAttribute('ytpa-random', 'applied'); newButton.addEventListener('click', event => { markWatched(videoId); playNextRandom(); }); } }, 1000); }; setInterval(applyRandomPlay, 1000); })(); })().catch( error => console.error( '%cYTPA - YouTube Play All\n', 'color: #bf4bcc; font-size: 32px; font-weight: bold', error, ) );