YouTube Playlist Autoplay Button

Allows the user to toggle autoplaying to the next video once the current video ends. Stores the setting locally.

  1. // ==UserScript==
  2. // @name YouTube Playlist Autoplay Button
  3. // @description Allows the user to toggle autoplaying to the next video once the current video ends. Stores the setting locally.
  4. // @version 2.0.8
  5. // @license GNU GPLv3
  6. // @match https://www.youtube.com/*
  7. // @namespace https://gf.qytechs.cn/users/701907
  8. // @require https://cdn.jsdelivr.net/gh/cyfung1031/userscript-supports@8fac46500c5a916e6ed21149f6c25f8d1c56a6a3/library/ytZara.js
  9. // @require https://cdn.jsdelivr.net/gh/cyfung1031/userscript-supports@7221a4efffd49d852de0074ec503d4febb99f28b/library/nextBrowserTick.min.js
  10. // @run-at document-start
  11. // @unwrap
  12. // @inject-into page
  13. // @noframes
  14. // ==/UserScript==
  15.  
  16. /**
  17. *
  18. * This is based on the [YouTube Prevent Playlist Autoplay](https://gf.qytechs.cn/en/scripts/415542-youtube-prevent-playlist-autoplay)
  19. * GNU GPLv3 license, credited to [MegaScientifical](https://gf.qytechs.cn/en/users/701907-megascientifical) (https://www.github.com/MegaScience)
  20. *
  21. **/
  22.  
  23. /**
  24. * This script now is maintained by [CY Fung](https://gf.qytechs.cn/en/users/371179)
  25. * It uses the technlogy same as Tabview Youtube and YouTube Super Fast Chat to achieve the robust implementation and high performance.
  26. *
  27. * This userscript supports Violentmonkey, Tampermonkey, Firemonkey, Stay, MeddleMonkey, etc. EXCEPT GreaseMonkey.
  28. *
  29. **/
  30.  
  31. /**
  32.  
  33. Copyright (c) 2020-2023 MegaScientifical
  34. Copyright (c) 2023 CY Fung
  35.  
  36. This program is free software; you can redistribute it and/or
  37. modify it under the terms of the GNU General Public License
  38. as published by the Free Software Foundation; either version 3
  39. of the License, or any later version.
  40.  
  41. This program is distributed in the hope that it will be useful,
  42. but WITHOUT ANY WARRANTY; without even the implied warranty of
  43. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  44. GNU General Public License for more details.
  45.  
  46. You should have received a copy of the GNU General Public License
  47. along with this program. If not, see http://www.gnu.org/licenses/.
  48.  
  49.  
  50. **/
  51.  
  52. (async () => {
  53.  
  54. const { insp, indr, isYtHidden } = ytZara;
  55. const Promise = (async () => { })().constructor;
  56.  
  57. let debug = false
  58. const elementCSS = {
  59. parent: [
  60. '#playlist-action-menu[autoplay-container="1"] .top-level-buttons', // Playlist parent area in general.
  61. 'ytd-playlist-panel-renderer[playlist-type] #playlist-action-menu[autoplay-container="2"]' // Playlist parent area for Mixes.
  62. ],
  63. cssId: 'YouTube-Prevent-Playlist-Autoplay-Style', // ID for the Style element to be injected into the page.
  64. buttonOn: 'YouTube-Prevent-Playlist-Autoplay-Button-On',
  65. buttonContainer: 'YouTube-Prevent-Playlist-Autoplay-Button-Container',
  66. buttonBar: 'YouTube-Prevent-Playlist-Autoplay-Button-Bar',
  67. buttonCircle: 'YouTube-Prevent-Playlist-Autoplay-Button-Circle'
  68. }
  69. const prefix = 'YouTube Prevent Playlist Autoplay:'
  70. const localStorageProperty = 'YouTubePreventPlaylistAutoplayStatus'
  71. // Get current autoplay setting from local storage.
  72. let autoplayStatus = loadAutoplayStatus()
  73. let transition = false
  74. let navigateStatus = -1;
  75. let fCounter = 0;
  76.  
  77. // Instead of writing the same log function prefix throughout
  78. // the code, this function automatically applies the prefix.
  79. const customLog = (...inputs) => console.log(prefix, ...inputs)
  80.  
  81. // Functions to get/set if you have autoplay off or on.
  82. // This applies to localStorage of the domain, so
  83. // clearing that will clear the stored value.
  84. function loadAutoplayStatus() {
  85. if (debug) customLog('Loading autoplay status.')
  86. return window.localStorage.getItem(localStorageProperty) === 'true'
  87. }
  88.  
  89. function saveAutoplayStatus() {
  90. if (debug) customLog('Saving autoplay status.')
  91. window.localStorage.setItem(localStorageProperty, autoplayStatus)
  92. }
  93.  
  94. // Ancient, common function for adding a style to the page.
  95. function addStyle(id, css) {
  96. if (document.getElementById(id) !== null) {
  97. if (debug) customLog('CSS has already been applied.')
  98. return
  99. }
  100. const head = document.head || document.getElementsByTagName('head')[0]
  101. if (!head) {
  102. if (debug) customLog('document.head is missing.')
  103. return
  104. }
  105. const style = document.createElement('style')
  106. style.id = id
  107. style.textContent = css
  108. head.appendChild(style)
  109. }
  110.  
  111. // Sets the ability to autoplay based on the user's current setting,
  112. // then sets the state of all autoplay toggle switches in the page.
  113. function setAssociatedAutoplay() {
  114. const manager = getManager()
  115. if (!manager) {
  116. if (debug) customLog('Manager is missing.')
  117. return
  118. }
  119. if (typeof manager.canAutoAdvance_ !== 'boolean') {
  120. customLog('manager.canAutoAdvance_ is not boolean');
  121. } else {
  122. if (navigateStatus !== 1) manager.canAutoAdvance_ = !!autoplayStatus;
  123. }
  124. for (const b of document.body.getElementsByClassName(elementCSS.buttonContainer)) {
  125. b.classList.toggle(elementCSS.buttonOn, autoplayStatus)
  126. b.setAttribute('title', `Autoplay is ${autoplayStatus ? 'on' : 'off'}`)
  127. }
  128. }
  129.  
  130. // Toggles the ability to autoplay, then sets the rest
  131. // and stores the current status of autoplay locally.
  132. function toggleAutoplay(e) {
  133. e.stopPropagation()
  134. if (transition) {
  135. if (debug) customLog('Button is transitioning.')
  136. e.preventDefault()
  137. return
  138. }
  139. autoplayStatus = !autoplayStatus
  140. setAssociatedAutoplay()
  141. saveAutoplayStatus()
  142. if (debug) customLog('Autoplay toggled to:', autoplayStatus)
  143. }
  144.  
  145. // Retrieves the current playlist manager to adjust and use.
  146. function getManager() {
  147. return insp(document.querySelector('yt-playlist-manager'));
  148. }
  149.  
  150. // Playlists cannot autoplay if the variable "canAutoAdvance_" is set to false.
  151. // It is messy to toggle back since various functions switch it.
  152. // Luckily, all attempts to set it to true are done through the same function.
  153. // By replacing this function, autoplay can be controlled by the user.
  154. function interceptManagerForAutoplay() {
  155. const manager = getManager()
  156. if (!manager) {
  157. if (debug) customLog('Manager is missing.')
  158. return
  159. }
  160. if (manager.interceptedForAutoplay) return
  161. manager.interceptedForAutoplay = true
  162. addStyle(elementCSS.cssId, elementCSS.styleText)
  163. if (debug) customLog('Autoplay is now controlled.')
  164. }
  165.  
  166. const transitionOn = () => {
  167. transition = true;
  168. // container.style.pointerEvents = 'none';
  169. }
  170. const transitionOff = () => {
  171. transition = false;
  172. // container.style.pointerEvents = '';
  173. }
  174.  
  175. const moButtonAttachment = new MutationObserver((entries) => {
  176. for (const entry of entries) {
  177. const { target, previousSibling, removedNodes } = entry;
  178. if (removedNodes.length >= 1 && target.isConnected === true && previousSibling && previousSibling.isConnected === true) {
  179. for (const elem of removedNodes) {
  180. if (elem.classList.contains(`${elementCSS.buttonContainer}`) && elem.isConnected === false) {
  181. target.insertBefore(elem, previousSibling.nextSibling);
  182. }
  183. }
  184. }
  185. }
  186. })
  187.  
  188. function appendButtonContainer(domElement) {
  189. if (!domElement || !(domElement instanceof Element) || !elementCSS.buttonContainer || domElement.querySelector(`.${elementCSS.buttonContainer}`)) return;
  190. const container = document.createElement('div')
  191. container.classList.add(elementCSS.buttonContainer)
  192. container.classList.toggle(elementCSS.buttonOn, autoplayStatus)
  193. container.setAttribute('title', `Autoplay is ${autoplayStatus ? 'on' : 'off'}`)
  194. container.addEventListener('click', toggleAutoplay, false)
  195. // if (debug && e) container.event = [...e]
  196.  
  197. const bar = document.createElement('div')
  198. bar.classList.add(elementCSS.buttonBar)
  199. container.appendChild(bar)
  200.  
  201. const circle = document.createElement('div')
  202. circle.classList.add(elementCSS.buttonCircle)
  203. // Use the transition as the cooldown.
  204. circle.addEventListener('transitionrun', transitionOn, { passive: true, capture: false });
  205. circle.addEventListener('transitionend', transitionOff, { passive: true, capture: false });
  206. circle.addEventListener('transitioncancel', transitionOff, { passive: true, capture: false });
  207. container.appendChild(circle)
  208.  
  209. domElement.appendChild(container)
  210. if (debug) customLog('Button added.')
  211.  
  212. moButtonAttachment.observe(domElement, { childList: true, subtree: false }); // re-adding after removal
  213.  
  214. }
  215.  
  216. function appendButtonContainerToMenu(menu) {
  217. if (!menu || !(menu instanceof Element)) return;
  218. const headers = menu.querySelectorAll('.top-level-buttons:not([hidden])')
  219. if (headers.length >= 1) {
  220. for (const header of headers) {
  221. // add button to each matched header, ignore those have been proceeded without re-rendering.
  222. appendButtonContainer(header);
  223. }
  224. menu.setAttribute('autoplay-container', '1');
  225. } else {
  226. // add button to the menu if no header is found, ignore those have been proceeded without re-rendering.
  227. appendButtonContainer(menu);
  228. menu.setAttribute('autoplay-container', '2');
  229. }
  230. }
  231.  
  232. const ytReady = new Promise(_resolve => {
  233. document.addEventListener('yt-action', async function () {
  234. const resolve = _resolve;
  235. _resolve = null;
  236. if (!resolve) return;
  237. await customElements.whenDefined('yt-playlist-manager').then();
  238. await new Promise(resolve => setTimeout(resolve, 100));
  239. resolve();
  240. }, { once: true, passive: true, capture: true });
  241. })
  242.  
  243. async function setupMenu(menu) {
  244. if (!(menu instanceof Element)) return;
  245. await ytReady.then();
  246.  
  247. // YouTube can have multiple variations of the playlist UI hidden in the page.
  248. // For instance, the sidebar and corner playlists. They also misuse IDs,
  249. // whereas they can appear multiple times in the same page.
  250. // This isolates one potentially visible instance.
  251. if (isYtHidden(menu)) {
  252. // the menu is invalid
  253. menu.removeAttribute('autoplay-container');
  254. } else {
  255. interceptManagerForAutoplay()
  256. appendButtonContainerToMenu(menu);
  257. setAssociatedAutoplay() // set canAutoAdvance_ when the page is loaded.
  258. }
  259. }
  260.  
  261. function onNavigateStart() { // navigation endpoint is clicked
  262. // canAutoAdvance_ will become false in onYtNavigateStart_
  263. navigateStatus = 1;
  264. if (fCounter > 1e9) fCounter = 9;
  265. fCounter++;
  266. }
  267.  
  268. function onNavigateCache() {
  269. navigateStatus = 1;
  270. if (fCounter > 1e9) fCounter = 9;
  271. fCounter++;
  272. }
  273.  
  274. function onNavigateFinish() {
  275. // canAutoAdvance_ will become true in onYtNavigateFinish_
  276. navigateStatus = 2;
  277. if (fCounter > 1e9) fCounter = 9;
  278. fCounter++;
  279. const t = fCounter;
  280. interceptManagerForAutoplay()
  281. setTimeout(() => {
  282. if (t !== fCounter) return;
  283. if (navigateStatus === 2) {
  284. // canAutoAdvance_ has become true in onYtNavigateFinish_
  285. setAssociatedAutoplay(); // set canAutoAdvance_ to true or false as per preferred setting
  286. }
  287. }, 100);
  288. }
  289.  
  290. const attrMo = new MutationObserver((entries) => {
  291. // the state of DOM is being changed, expand/collaspe state, rendering after dataChanged, etc.
  292. let m = new Set();
  293. for (const entry of entries) {
  294. m.add(entry.target); // avoid proceeding the same element target
  295. }
  296. m.forEach((target) => {
  297. if (target && target.isConnected === true) { // ensure the DOM is valid and attached to the document
  298. setupMenu(indr(target)['playlist-action-menu']); // add the button to the menu, if applicable
  299. }
  300. });
  301. m.clear();
  302. m = null;
  303. });
  304.  
  305. // listen events on the script execution in document-start
  306. document.addEventListener('yt-navigate-start', onNavigateStart, false);
  307. document.addEventListener('yt-navigate-cache', onNavigateCache, false);
  308. document.addEventListener('yt-navigate-finish', onNavigateFinish, false);
  309.  
  310.  
  311. elementCSS.styleText = `
  312. ${elementCSS.parent.join(', ')} {
  313. align-items: center;
  314. }
  315. .${elementCSS.buttonContainer} {
  316. position: relative;
  317. height: 20px;
  318. width: 36px;
  319. cursor: pointer;
  320. margin-left: 8px;
  321. }
  322. .${elementCSS.buttonContainer} .${elementCSS.buttonBar} {
  323. position: absolute;
  324. top: calc(50% - 7px);
  325. height: 14px;
  326. width: 36px;
  327. background-color: var(--paper-toggle-button-unchecked-bar-color, #000000);
  328. border-radius: 8px;
  329. opacity: 0.4;
  330. }
  331. .${elementCSS.buttonContainer} .${elementCSS.buttonCircle} {
  332. position: absolute;
  333. left: 0;
  334. height: 20px;
  335. width: 20px;
  336. background-color: var(--paper-toggle-button-unchecked-button-color, var(--paper-grey-50));
  337. border-radius: 50%;
  338. box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.6);
  339. transition: left linear .08s, background-color linear .08s;
  340. }
  341. .${elementCSS.buttonContainer}.${elementCSS.buttonOn} .${elementCSS.buttonCircle} {
  342. position: absolute;
  343. left: calc(100% - 20px);
  344. background-color: var(--paper-toggle-button-checked-button-color, var(--primary-color));
  345. }
  346. `;
  347.  
  348. if (!document.documentElement) await ytZara.docInitializedAsync(); // wait for document.documentElement is provided
  349.  
  350. await ytZara.promiseRegistryReady(); // wait for YouTube's customElement Registry is provided (old browser only)
  351.  
  352. const cProto = await ytZara.ytProtoAsync('ytd-playlist-panel-renderer'); // wait for customElement registration
  353.  
  354. if (cProto.attached145 || cProto.setupPlaylistActionMenu145) {
  355. console.warn('YouTube Playlist Autoplay Button cannot inject JS code to ytd-playlist-panel-renderer');
  356. return;
  357. }
  358.  
  359. cProto.attached145 = cProto.attached;
  360. cProto.setupPlaylistActionMenu145 = function () {
  361. nextBrowserTick(() => { // avoid blocking the DOM tree rendering
  362. const hostElement = this.hostElement;
  363. if (!hostElement || hostElement.isConnected !== true) return;
  364. attrMo.observe(hostElement, {
  365. attributes: true,
  366. attributeFilter: [
  367. 'has-playlist-buttons', 'has-toolbar', 'hidden', 'playlist-type', 'within-miniplayer', 'hide-header-text'
  368. ]
  369. });
  370. setupMenu(indr(this)['playlist-action-menu']); // add the button to the menu which is just attached to Dom Tree, if applicable
  371. });
  372. }
  373. cProto.attached = function () {
  374. try {
  375. this.setupPlaylistActionMenu145();
  376. } finally {
  377. const f = this.attached145;
  378. return f ? f.apply(this, arguments) : void 0;
  379. }
  380. }
  381.  
  382. if (debug) customLog('Initialized.')
  383.  
  384.  
  385. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址