NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name YouTube Play All // @description Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel // @version 20241109-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 // @grant 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', `<style> .ytpa-btn { border-radius: 8px; font-family: 'Roboto', 'Arial', sans-serif; font-size: 1.4rem; line-height: 2rem; font-weight: 500; padding: 0.5em; margin-left: 0.6em; user-select: none; } .ytpa-btn, .ytpa-btn > * { text-decoration: none; cursor: pointer; } .ytpa-btn-sections { padding: 0; } .ytpa-btn-sections > .ytpa-btn-section { padding: 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-play-all-btn { background-color: #bf4bcc; color: white; } .ytpa-play-all-btn:hover { background-color: #d264de; } .ytpa-random-btn > .ytpa-btn-section, .ytpa-random-badge, .ytpa-random-notice, .ytpa-random-popover > * { background-color: #2b66da; color: white; } .ytpa-random-btn > .ytpa-btn-section:hover, .ytpa-random-popover > *:hover { background-color: #6192ee; } .ytpa-random-popover { position: absolute; border-radius: 8px; font-size: 1.6rem; transform: translate(-100%, 0.4em); } .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-left: 0; margin-right: 12px; padding: 0.4em; } 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([data-tooltip-text="Random"]) { display: none !important; } #secondary 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 { filter: blur(2em); } .ytpa-random-notice { padding: 1em; z-index: 1000; } </style>`); 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', '<div class="ytpa-button-container"></div>'); 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<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 (parent.querySelector(':nth-child(2).selected, :nth-child(2).iron-selected')) { parent.insertAdjacentHTML( 'beforeend', `<a class="ytpa-btn ytpa-play-all-btn" href="/playlist?list=${popularPlaylist}${id}&playnext=1">Play Popular</a>` ); } else { parent.insertAdjacentHTML( 'beforeend', `<a class="ytpa-btn ytpa-play-all-btn" href="/playlist?list=${allPlaylist}${id}&playnext=1">Play All</a>` ); } 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', ` <span class="ytpa-btn ytpa-random-btn ytpa-btn-sections"> <a class="ytpa-btn-section" href="/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=random&ytpa-random-initial=1"> Play Random </a> <span class="ytpa-btn-section ytpa-random-more-options-btn ytpa-hover-popover"> ▾ </span> </span> `); document.body.insertAdjacentHTML('beforeend', ` <div class="ytpa-random-popover" hidden=""> <a href="/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-newest"> Prefer newest </a> <a href="/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-oldest&ytpa-random-initial=1"> Prefer oldest </a> </div> `); 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('<link rel="canonical" href="https://www.youtube.com/channel/UC') + 60 + 2 /* ID starts with "UC" */; id = html.substring(i, i + 22); // Initially generate button apply(); // Regenerate button if switched between Latest and Popular const element = document.querySelector('ytd-rich-grid-renderer'); if (!element) { return; } observer.observe(element, { attributes: true, childList: false, subtree: false }); }; // 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 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', ''), ); }; // Storage needs to now be { [videoId]: bool } try { if (Array.isArray(getStorage())) { localStorage.removeItem(getStorageKey()); } } catch (e) { localStorage.removeItem(getStorageKey()); } const playNextRandom = () => { 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); } 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()}`; }; 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', ` <div class="ytpa-random-notice"> This playlist is using random play.<br> The videos will <strong>not be played in the order</strong> listed here. </div> `) 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 += ` <span class="ytpa-badge ytpa-random-badge">${ytpaRandom} <span style="font-size: 2rem; vertical-align: top">×</span></span>`; 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 => { if (event.shiftKey && event.key.toLowerCase() === 'n') { event.stopPropagation(); event.preventDefault(); const videoId = getVideoId(location.href); markWatched(videoId); playNextRandom(); } }); 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()}`); /** * @var {{ getProgressState: () => { current: number, duration, number }, pauseVideo: () => void, isLifaAdPlaying: () => boolean }} player */ const player = document.querySelector('#movie_player'); const progressState = player.getProgressState(); // Do not listen for watch progress when watching advertisements if (!player.isLifaAdPlaying()) { 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([data-tooltip-text="Random"])'); if (nextButton) { nextButton.setAttribute('data-preview', ''); nextButton.setAttribute('data-tooltip-text', 'Random'); nextButton.setAttribute('ytpa-random', 'applied'); nextButton.addEventListener('click', event => { event.preventDefault(); 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, ) );