// ==UserScript==
// @name YouTube Play All
// @description Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel
// @version 20250615-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 GM.xmlHttpRequest
// @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', ``);
const getVideoId = url => new URLSearchParams(new URL(url).search).get('v');
/**
* @return {{ getProgressState: () => { current: number, duration, number }, pauseVideo: () => void, seekTo: (number) => void, isLifaAdPlaying: () => boolean }} player
*/
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
} 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 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);
}
// Fallback playlist emulation
(() => {
const getItems = playlist => {
return new Promise(resolve => {
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);
element.addEventListener('click', () => redirect(item.videoId, list));
itemsContainer.append(element);
},
);
markCurrentItem(params.get('v'));
};
const playNextEmulationItem = () => {
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);
const list = params.get('list');
if (params.has('ytpa-random')) {
return;
}
// 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;
}
const playlistEmulator = document.createElement('div');
playlistEmulator.className = 'ytpa-playlist-emulator';
playlistEmulator.innerHTML = `
Playlist emulator
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.
`;
playlistEmulator.setAttribute('data-list', list);
document.querySelector('#secondary-inner > ytd-playlist-panel-renderer#playlist').insertAdjacentElement('afterend', playlistEmulator);
getItems(list).then(response => {
if (response.status === 'running') {
setTimeout(() => getItems(list).then(response => processItems(response.items)), 5000);
return;
}
processItems(response.items);
});
const nextButtonInterval = setInterval(() => {
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 = document.createElement('span');
newButton.className = nextButton.className;
newButton.innerHTML = nextButton.innerHTML;
nextButton.replaceWith(newButton);
newButton.setAttribute('ytpa-emulation', 'applied');
newButton.addEventListener('click', () => playNextEmulationItem());
}
}, 1000);
document.addEventListener('keydown', event => {
// SHIFT + N
if (event.shiftKey && event.key.toLowerCase() === 'n') {
event.stopImmediatePropagation();
event.preventDefault();
playNextEmulationItem();
}
}, true);
setInterval(() => {
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 {
window.addEventListener('yt-navigate-finish', () => setTimeout(emulatePlaylist, 1000));
}
})();
// Random play feature
(() => {
// Random play is not supported for mobile devices
if (location.host === 'm.youtube.com') {
return;
}
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has('ytpa-random') || urlParams.get('ytpa-random') === '0') {
return;
}
/**
* @type {'random'|'prefer-newest'|'prefer-oldest'}
*/
const ytpaRandom = urlParams.get('ytpa-random');
const getStorageKey = () => `ytpa-random-${urlParams.get('list')}`;
const getStorage = () => JSON.parse(localStorage.getItem(getStorageKey()) || '{}');
const isWatched = videoId => getStorage()[videoId] || false;
const markWatched = videoId => {
localStorage.setItem(getStorageKey(), JSON.stringify({...getStorage(), [videoId]: true }));
document.querySelectorAll('#wc-endpoint[href*=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 = (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 {
// TODO: refactor to the new redirect() function
const redirector = document.createElement('a');
redirector.className = 'yt-simple-endpoint style-scope ytd-playlist-panel-video-renderer';
redirector.setAttribute('hidden', '');
redirector.data = {
'commandMetadata': {
'webCommandMetadata': {
'url': `/watch?v=${videos[videoIndex][0]}&list=${params.get('list')}&ytpa-random=${ytpaRandom}`,
'webPageType': 'WEB_PAGE_TYPE_WATCH',
'rootVe': 3832, // ??? required though
}
},
'watchEndpoint': {
'videoId': videos[videoIndex][0],
'playlistId': params.get('list'),
}
};
document.querySelector('ytd-playlist-panel-renderer #items').append(redirector);
redirector.click();
}
};
let isIntervalSet = false;
const applyRandomPlay = () => {
if (!window.location.pathname.endsWith('/watch')) {
return;
}
const playlistContainer = document.querySelector('#secondary ytd-playlist-panel-renderer');
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') {
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;
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();
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 = 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,
)
);