🏠 Home 

YouTube Play All

Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel

  1. // ==UserScript==
  2. // @name YouTube Play All
  3. // @description Adds the Play-All-Button to the videos, shorts, and live sections of a YouTube-Channel
  4. // @version 20241109-0
  5. // @author Robert Wesner (https://robert.wesner.io)
  6. // @license MIT
  7. // @namespace http://robert.wesner.io/
  8. // @match https://*.youtube.com/*
  9. // @icon https://scripts.yt/favicon.ico
  10. // @grant none
  11. // ==/UserScript==
  12. /**
  13. * @var {{ defaultPolicy: any, createPolicy: (string, Object) => void }} window.trustedTypes
  14. */
  15. /**
  16. * @var {{ script: { version: string } }} GM_info
  17. */
  18. (async function () {
  19. 'use strict';
  20. const scriptVersion = GM_info.script.version || null;
  21. if (scriptVersion && /-(alpha|beta|dev|test)$/.test(scriptVersion)) {
  22. console.log(
  23. '%cYTPA - YouTube Play All\n',
  24. 'color: #bf4bcc; font-size: 32px; font-weight: bold',
  25. 'You are currently running a test version:',
  26. scriptVersion,
  27. );
  28. }
  29. if (window.hasOwnProperty('trustedTypes') && !window.trustedTypes.defaultPolicy) {
  30. window.trustedTypes.createPolicy('default', { createHTML: string => string });
  31. }
  32. document.head.insertAdjacentHTML('beforeend', `<style>
  33. .ytpa-btn {
  34. border-radius: 8px;
  35. font-family: 'Roboto', 'Arial', sans-serif;
  36. font-size: 1.4rem;
  37. line-height: 2rem;
  38. font-weight: 500;
  39. padding: 0.5em;
  40. margin-left: 0.6em;
  41. user-select: none;
  42. }
  43. .ytpa-btn, .ytpa-btn > * {
  44. text-decoration: none;
  45. cursor: pointer;
  46. }
  47. .ytpa-btn-sections {
  48. padding: 0;
  49. }
  50. .ytpa-btn-sections > .ytpa-btn-section {
  51. padding: 0.5em;
  52. }
  53. .ytpa-btn-sections > .ytpa-btn-section:first-child {
  54. border-top-left-radius: 8px;
  55. border-bottom-left-radius: 8px;
  56. }
  57. .ytpa-btn-sections > .ytpa-btn-section:nth-last-child(1 of .ytpa-btn-section) {
  58. border-top-right-radius: 8px;
  59. border-bottom-right-radius: 8px;
  60. }
  61. .ytpa-badge {
  62. border-radius: 8px;
  63. padding: 0.2em;
  64. font-size: 0.8em;
  65. vertical-align: top;
  66. }
  67. .ytpa-play-all-btn {
  68. background-color: #bf4bcc;
  69. color: white;
  70. }
  71. .ytpa-play-all-btn:hover {
  72. background-color: #d264de;
  73. }
  74. .ytpa-random-btn > .ytpa-btn-section, .ytpa-random-badge, .ytpa-random-notice, .ytpa-random-popover > * {
  75. background-color: #2b66da;
  76. color: white;
  77. }
  78. .ytpa-random-btn > .ytpa-btn-section:hover, .ytpa-random-popover > *:hover {
  79. background-color: #6192ee;
  80. }
  81. .ytpa-random-popover {
  82. position: absolute;
  83. border-radius: 8px;
  84. font-size: 1.6rem;
  85. transform: translate(-100%, 0.4em);
  86. }
  87. .ytpa-random-popover > * {
  88. display: block;
  89. text-decoration: none;
  90. padding: 0.4em;
  91. }
  92. .ytpa-random-popover > :first-child {
  93. border-top-left-radius: 8px;
  94. border-top-right-radius: 8px;
  95. }
  96. .ytpa-random-popover > :last-child {
  97. border-bottom-left-radius: 8px;
  98. border-bottom-right-radius: 8px;
  99. }
  100. .ytpa-random-popover > *:not(:last-child) {
  101. border-bottom: 1px solid #6e8dbb;
  102. }
  103. .ytpa-button-container {
  104. display: flex;
  105. width: 100%;
  106. margin-top: 1em;
  107. margin-bottom: -1em;
  108. }
  109. ytd-rich-grid-renderer .ytpa-button-container > :first-child {
  110. margin-left: 0;
  111. }
  112. /* fetch() API introduces a race condition. This hides the occasional duplicate buttons */
  113. .ytpa-play-all-btn ~ .ytpa-play-all-btn,
  114. .ytpa-random-btn ~ .ytpa-random-btn {
  115. display: none;
  116. }
  117. /* Fix for mobile view */
  118. ytm-feed-filter-chip-bar-renderer .ytpa-btn {
  119. margin-left: 0;
  120. margin-right: 12px;
  121. padding: 0.4em;
  122. }
  123. body:has(#secondary ytd-playlist-panel-renderer[ytpa-random]) .ytp-prev-button.ytp-button,
  124. body:has(#secondary ytd-playlist-panel-renderer[ytpa-random]) .ytp-next-button.ytp-button:not([data-tooltip-text="Random"]) {
  125. display: none !important;
  126. }
  127. #secondary ytd-playlist-panel-renderer[ytpa-random] ytd-menu-renderer.ytd-playlist-panel-renderer {
  128. height: 1em;
  129. visibility: hidden;
  130. }
  131. #secondary ytd-playlist-panel-renderer[ytpa-random]:not(:hover) ytd-playlist-panel-video-renderer {
  132. filter: blur(2em);
  133. }
  134. .ytpa-random-notice {
  135. padding: 1em;
  136. z-index: 1000;
  137. }
  138. </style>`);
  139. let id;
  140. const apply = () => {
  141. let parent = location.host === 'm.youtube.com'
  142. // mobile view
  143. ? document.querySelector('ytm-feed-filter-chip-bar-renderer > div')
  144. // desktop view
  145. : document.querySelector('ytd-feed-filter-chip-bar-renderer iron-selector#chips');
  146. // #5: add a custom container for buttons if Latest/Popular/Oldest is missing
  147. if (parent === null) {
  148. const grid = document.querySelector('ytd-rich-grid-renderer, ytm-rich-grid-renderer');
  149. grid.insertAdjacentHTML('afterbegin', '<div class="ytpa-button-container"></div>');
  150. parent = grid.querySelector('.ytpa-button-container');
  151. }
  152. // See: available-lists.md
  153. let [allPlaylist, popularPlaylist] = window.location.pathname.endsWith('/videos')
  154. // Normal videos
  155. // list=UULP has the all videos sorted by popular
  156. // list=UU<ID> adds shorts into the playlist, list=UULF<ID> has videos without shorts
  157. ? ['UULF', 'UULP']
  158. // Shorts
  159. : window.location.pathname.endsWith('/shorts')
  160. ? ['UUSH', 'UUPS']
  161. // Live streams
  162. : ['UULV', 'UUPV'];
  163. // Check if popular videos are displayed
  164. if (parent.querySelector(':nth-child(2).selected, :nth-child(2).iron-selected')) {
  165. parent.insertAdjacentHTML(
  166. 'beforeend',
  167. `<a class="ytpa-btn ytpa-play-all-btn" href="/playlist?list=${popularPlaylist}${id}&playnext=1">Play Popular</a>`
  168. );
  169. } else {
  170. parent.insertAdjacentHTML(
  171. 'beforeend',
  172. `<a class="ytpa-btn ytpa-play-all-btn" href="/playlist?list=${allPlaylist}${id}&playnext=1">Play All</a>`
  173. );
  174. }
  175. if (location.host === 'm.youtube.com') {
  176. // YouTube returns an "invalid response" when using client side routing for playnext=1 on mobile
  177. document.querySelectorAll('.ytpa-btn').forEach(btn => btn.addEventListener('click', event => {
  178. event.preventDefault();
  179. window.location.href = btn.href;
  180. }));
  181. } else {
  182. // Only allow random play in desktop version for now
  183. parent.insertAdjacentHTML('beforeend', `
  184. <span class="ytpa-btn ytpa-random-btn ytpa-btn-sections">
  185. <a class="ytpa-btn-section" href="/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=random&ytpa-random-initial=1">
  186. Play Random
  187. </a>
  188. <span class="ytpa-btn-section ytpa-random-more-options-btn ytpa-hover-popover">
  189. &#x25BE
  190. </span>
  191. </span>
  192. `);
  193. document.body.insertAdjacentHTML('beforeend', `
  194. <div class="ytpa-random-popover" hidden="">
  195. <a href="/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-newest">
  196. Prefer newest
  197. </a>
  198. <a href="/playlist?list=${allPlaylist}${id}&playnext=1&ytpa-random=prefer-oldest&ytpa-random-initial=1">
  199. Prefer oldest
  200. </a>
  201. </div>
  202. `);
  203. const randomMoreOptionsBtn = document.querySelector('.ytpa-random-more-options-btn');
  204. const randomPopover = document.querySelector('.ytpa-random-popover');
  205. randomMoreOptionsBtn.addEventListener('click', () => {
  206. const rect = randomMoreOptionsBtn.getBoundingClientRect();
  207. randomPopover.style.top = rect.bottom.toString() + 'px';
  208. randomPopover.style.left = rect.right.toString() + 'px';
  209. randomPopover.removeAttribute('hidden');
  210. });
  211. randomPopover.addEventListener('mouseleave', () => {
  212. randomPopover.setAttribute('hidden', '');
  213. });
  214. }
  215. };
  216. const observer = new MutationObserver(apply);
  217. const addButton = async () => {
  218. observer.disconnect();
  219. if (!(window.location.pathname.endsWith('/videos') || window.location.pathname.endsWith('/shorts') || window.location.pathname.endsWith('/streams'))) {
  220. return;
  221. }
  222. // This check is necessary for the mobile Interval
  223. if (document.querySelector('.ytpa-play-all-btn')) {
  224. return;
  225. }
  226. const html = await (await fetch('.')).text();
  227. const i = html.indexOf('<link rel="canonical" href="https://www.youtube.com/channel/UC') + 60 + 2 /* ID starts with "UC" */;
  228. id = html.substring(i, i + 22);
  229. // Initially generate button
  230. apply();
  231. // Regenerate button if switched between Latest and Popular
  232. const element = document.querySelector('ytd-rich-grid-renderer');
  233. if (!element) {
  234. return;
  235. }
  236. observer.observe(element, {
  237. attributes: true,
  238. childList: false,
  239. subtree: false
  240. });
  241. };
  242. // Removing the button prevents it from still existing when switching between "Videos", "Shorts", and "Live"
  243. // This is necessary due to the mobile Interval requiring a check for an already existing button
  244. const removeButton = () => document.querySelectorAll('.ytpa-btn').forEach(element => element.remove());
  245. if (location.host === 'm.youtube.com') {
  246. // The "yt-navigate-finish" event does not fire on mobile
  247. // Unfortunately pushState is triggered before the navigation occurs, so a Proxy is useless
  248. setInterval(addButton, 1000);
  249. } else {
  250. window.addEventListener('yt-navigate-start', removeButton);
  251. window.addEventListener('yt-navigate-finish', addButton);
  252. }
  253. // Random play feature
  254. (() => {
  255. // Random play is not supported for mobile devices
  256. if (location.host === 'm.youtube.com') {
  257. return;
  258. }
  259. const urlParams = new URLSearchParams(window.location.search);
  260. if (!urlParams.has('ytpa-random') || urlParams.get('ytpa-random') === '0') {
  261. return;
  262. }
  263. /**
  264. * @type {'random'|'prefer-newest'|'prefer-oldest'}
  265. */
  266. const ytpaRandom = urlParams.get('ytpa-random');
  267. const getVideoId = url => new URLSearchParams(new URL(url).search).get('v');
  268. const getStorageKey = () => `ytpa-random-${urlParams.get('list')}`;
  269. const getStorage = () => JSON.parse(localStorage.getItem(getStorageKey()) || '{}');
  270. const isWatched = videoId => getStorage()[videoId] || false;
  271. const markWatched = videoId => {
  272. localStorage.setItem(getStorageKey(), JSON.stringify({...getStorage(), [videoId]: true }));
  273. document.querySelectorAll('#wc-endpoint[href*=zsA3X40nz9w]').forEach(
  274. element => element.parentElement.setAttribute('hidden', ''),
  275. );
  276. };
  277. // Storage needs to now be { [videoId]: bool }
  278. try {
  279. if (Array.isArray(getStorage())) {
  280. localStorage.removeItem(getStorageKey());
  281. }
  282. } catch (e) {
  283. localStorage.removeItem(getStorageKey());
  284. }
  285. const playNextRandom = () => {
  286. const videos = Object.entries(getStorage()).filter(([_, watched]) => !watched);
  287. const params = new URLSearchParams(window.location.search);
  288. // Either one fifth or at most the 20 newest.
  289. const preferenceRange = Math.min(Math.min(videos.length * 0.2, 20))
  290. let videoIndex;
  291. switch (ytpaRandom) {
  292. case 'prefer-newest':
  293. // Select between latest 20 videos
  294. videoIndex = Math.floor(Math.random() * preferenceRange);
  295. break;
  296. case 'prefer-oldest':
  297. // Select between oldest 20 videos
  298. videoIndex = videos.length - Math.floor(Math.random() * preferenceRange);
  299. break;
  300. default:
  301. videoIndex = Math.floor(Math.random() * videos.length);
  302. }
  303. params.set('v', videos[videoIndex][0]);
  304. params.set('ytpa-random', ytpaRandom);
  305. params.delete('t');
  306. params.delete('index');
  307. params.delete('ytpa-random-initial');
  308. window.location.href = `${window.location.pathname}?${params.toString()}`;
  309. };
  310. let isIntervalSet = false;
  311. const applyRandomPlay = () => {
  312. if (!window.location.pathname.endsWith('/watch')) {
  313. return;
  314. }
  315. const playlistContainer = document.querySelector('#secondary ytd-playlist-panel-renderer');
  316. if (playlistContainer === null) {
  317. return;
  318. }
  319. if (playlistContainer.hasAttribute('ytpa-random')) {
  320. return;
  321. }
  322. playlistContainer.setAttribute('ytpa-random', 'applied');
  323. playlistContainer.querySelector('.header').insertAdjacentHTML('afterend', `
  324. <div class="ytpa-random-notice">
  325. This playlist is using random play.<br>
  326. The videos will <strong>not be played in the order</strong> listed here.
  327. </div>
  328. `)
  329. const storage = getStorage();
  330. playlistContainer.querySelectorAll('#wc-endpoint').forEach(element => {
  331. const videoId = (new URLSearchParams(new URL(element.href).searchParams)).get('v');
  332. if (!isWatched(videoId)) {
  333. storage[videoId] = false;
  334. }
  335. element.href += '&ytpa-random=' + ytpaRandom;
  336. // This bypasses the client side routing
  337. element.addEventListener('click', event => {
  338. event.preventDefault();
  339. window.location.href = element.href;
  340. });
  341. const entryKey= getVideoId(element.href);
  342. if (isWatched(entryKey)) {
  343. element.parentElement.setAttribute('hidden', '');
  344. }
  345. });
  346. localStorage.setItem(getStorageKey(), JSON.stringify(storage));
  347. if (urlParams.get('ytpa-random-initial') === '1' || isWatched(getVideoId(location.href))) {
  348. playNextRandom();
  349. return;
  350. }
  351. const header = playlistContainer.querySelector('h3 a');
  352. header.innerHTML += ` <span class="ytpa-badge ytpa-random-badge">${ytpaRandom} <span style="font-size: 2rem; vertical-align: top">&times;</span></span>`;
  353. header.href = 'javascript:none';
  354. header.querySelector('.ytpa-random-badge').addEventListener('click', event => {
  355. event.preventDefault();
  356. localStorage.removeItem(getStorageKey());
  357. let params = new URLSearchParams(location.search);
  358. params.delete('ytpa-random');
  359. window.location.href = `${window.location.pathname}?${params.toString()}`;
  360. });
  361. document.addEventListener('keydown', event => {
  362. if (event.shiftKey && event.key.toLowerCase() === 'n') {
  363. event.stopPropagation();
  364. event.preventDefault();
  365. const videoId = getVideoId(location.href);
  366. markWatched(videoId);
  367. playNextRandom();
  368. }
  369. });
  370. if (isIntervalSet) {
  371. return;
  372. }
  373. isIntervalSet = true;
  374. setInterval(() => {
  375. const videoId = getVideoId(location.href);
  376. let params = new URLSearchParams(location.search);
  377. params.set('ytpa-random', ytpaRandom);
  378. window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
  379. /**
  380. * @var {{ getProgressState: () => { current: number, duration, number }, pauseVideo: () => void, isLifaAdPlaying: () => boolean }} player
  381. */
  382. const player = document.querySelector('#movie_player');
  383. const progressState = player.getProgressState();
  384. // Do not listen for watch progress when watching advertisements
  385. if (!player.isLifaAdPlaying()) {
  386. if (progressState.current / progressState.duration >= 0.9) {
  387. markWatched(videoId);
  388. }
  389. // Autoplay random video
  390. if (progressState.current >= progressState.duration - 2) {
  391. // make sure vanilla autoplay doesnt take over
  392. player.pauseVideo();
  393. playNextRandom();
  394. }
  395. }
  396. const nextButton = document.querySelector('#ytd-player .ytp-next-button.ytp-button:not([data-tooltip-text="Random"])');
  397. if (nextButton) {
  398. nextButton.setAttribute('data-preview', '');
  399. nextButton.setAttribute('data-tooltip-text', 'Random');
  400. nextButton.setAttribute('ytpa-random', 'applied');
  401. nextButton.addEventListener('click', event => {
  402. event.preventDefault();
  403. markWatched(videoId);
  404. playNextRandom();
  405. });
  406. }
  407. }, 1000);
  408. };
  409. setInterval(applyRandomPlay, 1000);
  410. })();
  411. })().catch(
  412. error => console.error(
  413. '%cYTPA - YouTube Play All\n',
  414. 'color: #bf4bcc; font-size: 32px; font-weight: bold',
  415. error,
  416. )
  417. );