Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel
- // ==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,
- )
- );