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.classList.contains('ytpa-button-container')) {
parent.insertAdjacentElement(
'beforeend',
safeBuildDynamicHtml(
() => buildElement(document.createElement('a'))
.className('ytpa-btn ytpa-play-all-btn')
.href(`/playlist?list=${popularPlaylist}${id}&playnext=1`)
.role('button')
.unwrap(),
element => element.textContent = 'Play Popular',
),
);
} else if (parent.querySelector(':nth-child(1).selected, :nth-child(1).iron-selected')) {
parent.insertAdjacentElement(
'beforeend',
safeBuildDynamicHtml(
() => buildElement(document.createElement('a'))
.className('ytpa-btn ytpa-play-all-btn')
.href(`/playlist?list=${allPlaylist}${id}&playnext=1`)
.role('button')
.unwrap(),
element => element.textContent = 'Play All',
),
);
} else {
parent.insertAdjacentElement(
'beforeend',
safeBuildDynamicHtml(
() => buildElement(document.createElement('a'))
.className('ytpa-btn ytpa-play-all-btn ytpa-unsupported')
.href(`https://github.com/RobertWesner/YouTube-Play-All/issues/39`)
.role('button')
.target('_blank')
.rel('noreferrer')
.unwrap(),
element => element.textContent = 'No Playlist Found',
),
);
}
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.insertAdjacentElement(
'beforeend',
safeBuildDynamicHtml(
() => buildElement(document.createElement('span'))
.className('ytpa-btn ytpa-random-btn ytpa-btn-sections')
.unwrap(),
element => element.append(
safeBuildDynamicHtml(
() => buildElement(document.createElement('a'))
.className('ytpa-btn-section')
.href(`/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=random&ytpa-random-initial=1`)
.role('button')
.unwrap(),
element => element.textContent = 'Play Random',
),
safeBuildDynamicHtml(
() => buildElement(document.createElement('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')
.unwrap(),
element => element.innerHTML = '▾',
),
safeBuildDynamicHtml(
() => buildElement(document.createElement('span'))
.className('ytpa-random-btn-tab-fix')
.tabindex('-1')
.aria_hidden('true')
.unwrap(),
element => element.innerHTML = '▾',
),
),
),
);
document.body.insertAdjacentElement(
'afterbegin',
safeBuildDynamicHtml(
() => buildElement(document.createElement('div'))
.className('ytpa-random-popover')
.role('menu')
.aria_label('Random play options')
.hidden('')
.unwrap(),
element => element.append(
safeBuildDynamicHtml(
() => buildElement(document.createElement('a'))
.href(`/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-newest`)
.aria_label('Play Random prefer newest')
.role('menuitem')
.unwrap(),
element => element.textContent = 'Prefer newest',
),
safeBuildDynamicHtml(
() => buildElement(document.createElement('a'))
.href(`/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-oldest&ytpa-random-initial=1`)
.aria_label('Play Random prefer oldest')
.role('menuitem')
.unwrap(),
element => element.textContent = 'Prefer oldest',
),
),
),
);
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();
};
randomMoreOptionsBtn.addEventListener('click', showPopover);
randomMoreOptionsBtn.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
showPopover();
}
});
randomPopover.addEventListener('mouseleave', hidePopover);
randomPopover.querySelector('a:last-of-type').addEventListener('focusout', hidePopover);
}
};
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()
// Regenerate button if switched between Latest and Popular
const element = document.querySelector('ytd-browse:not([hidden]) ytd-rich-grid-renderer, ytm-feed-filter-chip-bar-renderer .iron-selected, ytm-feed-filter-chip-bar-renderer .chip-bar-contents .selected');
if (element) {
observer.observe(element, {
attributes: true,
childList: false,
subtree: false
});
}
// This check is necessary for the mobile Interval
if (document.querySelector('.ytpa-play-all-btn')) {
return;
}
// Initially generate button
apply();
};
// Removing the button prevents it from still existing when switching between "Videos", "Shorts", and "Live"
// This is necessary due to the mobile Interval requiring a check for an already existing button
const removeButton = () => document.querySelectorAll('.ytpa-btn').forEach(element => element.remove());
if (location.host === 'm.youtube.com') {
// The "yt-navigate-finish" event does not fire on mobile
// Unfortunately pushState is triggered before the navigation occurs, so a Proxy is useless
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 => {
// 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);
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 = nextButton.cloneNode(true);
newButton.href = 'javascript:void(0)';
nextButton.replaceWith(newButton);
newButton.setAttribute('ytpa-emulation', 'applied');
newButton.addEventListener('click', () => playNextEmulationItem());
}
}, 1000);
// TODO: this does not look like it is called on the new UI,
// the new UI seems to preserves the GET-parameter on its own.
document.body.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*="${videoId}"]`).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) => {
// 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 = 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, #below 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();
// ensure all the links are "corrected" to random play
const playlistElementsInterval = setInterval(() => {
const elements = playlistContainer.querySelectorAll('a#wc-endpoint:not([href*="&ytpa-random="])');
if (elements.length === 0) {
clearInterval(playlistElementsInterval);
return;
}
elements.forEach(element => {
const videoId = (new URLSearchParams(new URL(element.href).searchParams)).get('v');
if (!isWatched(videoId)) {
storage[videoId] = false;
}
element.href += '&ytpa-random=' + ytpaRandom;
// This bypasses the client side routing
element.addEventListener('click', event => {
event.preventDefault();
window.location.href = element.href;
});
const entryKey= getVideoId(element.href);
if (isWatched(entryKey)) {
element.parentElement.setAttribute('hidden', '');
}
});
}, 1000);
localStorage.setItem(getStorageKey(), JSON.stringify(storage));
if (urlParams.get('ytpa-random-initial') === '1' || isWatched(getVideoId(location.href))) {
playNextRandom();
return;
}
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 - 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');
newButton.addEventListener('click', event => {
markWatched(videoId);
playNextRandom();
});
}
}, 500);
};
setInterval(applyRandomPlay, 1000);
})();
})().catch(
error => console.error(
'%cYTPA - YouTube Play All\n',
'color: #bf4bcc; font-size: 32px; font-weight: bold',
error,
)
);
/**
* @var {{ defaultPolicy: any, createPolicy: (string, Object) => void }} window.trustedTypes
*/
/**
* @var {{ xmlHttpRequest: (object) => void }} GM
*/
/**
* @var {{ script: { version: string } }} GM_info
*/
/**
* @typedef {Object} WrappedElementBuilder
* @property {() => HTMLElement} unwrap
* @property {(string) => WrappedElementBuilder} className
* @property {(string) => WrappedElementBuilder} href
* @property {(string) => WrappedElementBuilder} target
* @property {(string) => WrappedElementBuilder} rel
* @property {(string) => WrappedElementBuilder} role
* @property {(string) => WrappedElementBuilder} tabindex
* @property {(string) => WrappedElementBuilder} hidden
* @property {(string) => WrappedElementBuilder} style
* @property {(string) => WrappedElementBuilder} aria_label
* @property {(string) => WrappedElementBuilder} aria_haspopup
* @property {(string) => WrappedElementBuilder} aria_expanded
* @property {(string) => WrappedElementBuilder} aria_hidden
*/