Play Youtube playlist in reverse order

Adds button for loading the previous video in a YT playlist

  1. // ==UserScript==
  2. // @name Play Youtube playlist in reverse order
  3. // @namespace https://github.com/Dragosarus/Userscripts/
  4. // @version 7.9
  5. // @description Adds button for loading the previous video in a YT playlist
  6. // @author Dragosarus
  7. // @match http://www.youtube.com/*
  8. // @match https://www.youtube.com/*
  9. // @grant none
  10. // @require http://code.jquery.com/jquery-latest.js
  11. // @noframes
  12. // ==/UserScript==
  13.  
  14. // Cookies (current session):
  15. // pytplir_playPrevious - saves the button state between loads
  16.  
  17. /* NOTES:
  18. * - If the button is not displayed (but the script is running), pause and unpause the video.
  19. * - If it still does not appear, reload the page.
  20. * - If it *still* does not appear, let me know through Greasy Fork镜像 or GitHub.
  21. * - If the button is displayed but does not work properly/consistently, increase the value of redirectWhenTimeLeft.
  22. */
  23.  
  24. (function() {
  25. 'use strict';
  26. $(document).ready(function() {
  27. // Determines when to load the next video.
  28. // Increase these if the redirect does not work as intended (i.e. fails to override Youtube's redirect),
  29. // Decreasing these will let you see more of the video before it redirects, but the redirect might stop working (consistently)
  30. const redirectWhenTimeLeft = 0.3; // seconds before the end of the video
  31. const redirectWhenTimeLeft_miniplayer = 0.6;
  32. const skipUnplayable = true; // Skip videos that have not been premiered yet/upcoming livestreams
  33.  
  34. const activeColor = "rgb(64,166,255)";
  35. const inactiveColor = "rgb(144,144,144)";
  36. const circleColor = "rgb(144,144,144)";
  37. const ttBGColor = "rgb(100,100,100)";
  38. const ttTextColor = "rgb(237,240,243)";
  39.  
  40. // Logs debug messages to the console.
  41. const debug = false;
  42.  
  43. const selectors = {
  44. "buttonLocation": "div[id=playlist-action-menu] > .ytd-playlist-panel-renderer > div[id=top-level-buttons-computed]",
  45. "content": "#content",
  46. "player": ".html5-main-video",
  47. "miniplayerDiv": "div.miniplayer",
  48. "playlistButtons": ".ytd-watch-flexy #playlist #playlist-action-menu",
  49. "playlistButtonsMiniplayer": "ytd-playlist-panel-renderer.ytd-miniplayer #playlist-action-menu",
  50. "playlistCurrentVideo": "ytd-playlist-panel-video-renderer[selected]",
  51. "playlistVideos": "#publisher-container span.index-message",
  52. "playlistVideosMiniplayer": "yt-formatted-string[id=owner-name] :nth-child(3)",
  53. "shuffleButtonActive": "path[d='M18.51,13.29l4.21,4.21l-4.21,4.21l-1.41-1.41l1.8-1.8c-2.95-0.03-5.73-1.32-7.66-3.55l1.51-1.31 c1.54,1.79,3.77,2.82,6.13,2.85l-1.79-1.79L18.51,13.29z M18.88,7.51l-1.78,1.78l1.41,1.41l4.21-4.21l-4.21-4.21l-1.41,1.41l1.8,1.8 c-3.72,0.04-7.12,2.07-8.9,5.34l-0.73,1.34C7.81,14.85,5.03,17,2,17v2c3.76,0,7.21-2.55,9.01-5.85l0.73-1.34 C13.17,9.19,15.9,7.55,18.88,7.51z M8.21,10.31l1.5-1.32C7.77,6.77,4.95,5,2,5v2C4.38,7,6.64,8.53,8.21,10.31z']",
  54. "shuffleButtonInactive": "path[d='M18.15,13.65l3.85,3.85l-3.85,3.85l-0.71-0.71L20.09,18H19c-2.84,0-5.53-1.23-7.39-3.38l0.76-0.65 C14.03,15.89,16.45,17,19,17h1.09l-2.65-2.65L18.15,13.65z M19,7h1.09l-2.65,2.65l0.71,0.71l3.85-3.85l-3.85-3.85l-0.71,0.71 L20.09,6H19c-3.58,0-6.86,1.95-8.57,5.09l-0.73,1.34C8.16,15.25,5.21,17,2,17v1c3.58,0,6.86-1.95,8.57-5.09l0.73-1.34 C12.84,8.75,15.79,7,19,7z M8.59,9.98l0.75-0.66C7.49,7.21,4.81,6,2,6v1C4.52,7,6.92,8.09,8.59,9.98z']",
  55. "shuffleButtonLegacy": "path[d='M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z']",
  56. "timestamp": "span.ytd-thumbnail-overlay-time-status-renderer",
  57. "videoPlayer": ".html5-video-player"
  58. }
  59.  
  60. const ytdApp = $("ytd-app")[0];
  61.  
  62. let player;
  63. let playPrevious;
  64. let redirectFlag = false;
  65. let shuffle;
  66. let miniplayerActive = false;
  67. let miniplayerFlag = false; // keep track of switches between miniplayer and normal mode
  68. let playerListenersAdded = false;
  69.  
  70. // create button
  71. const svgNS = "http://www.w3.org/2000/svg";
  72. const btn_div = document.createElement("div");
  73. const bg_circle = document.createElementNS(svgNS, "circle");
  74. const bg_circle_anim = document.createElementNS(svgNS, "animate");
  75. const arrow_up = document.createElementNS(svgNS, "polygon");
  76. const arrow_down = document.createElementNS(svgNS, "polygon");
  77. const btn_svg = document.createElementNS(svgNS, "svg");
  78. const tt_svg = document.createElementNS(svgNS, "svg");
  79. const tt_svg_fadein = document.createElementNS(svgNS, "animate");
  80. const tt_svg_fadeout = document.createElementNS(svgNS, "animate");
  81. const tt_rect = document.createElementNS(svgNS, "rect");
  82. const tt_text = document.createElementNS(svgNS, "text");
  83. const tt_div = document.createElement("div");
  84.  
  85. setAttributes(bg_circle_anim, [["attributeName", "fill-opacity"],
  86. ["values", "0;0.1;0.2;0.1;0.0"],
  87. ["dur", "0.3s"],
  88. ["restart", "always"],
  89. ["repeatCount", "1"],
  90. ["begin", "indefinite"],
  91. ["id", "pytplir_bg_circle_anim"]]);
  92. setAttributes(bg_circle, [["cx", "20"],
  93. ["cy", "20"],
  94. ["r", "20"],
  95. ["fill", circleColor],
  96. ["fill-opacity", "0"]]);
  97. setAttributes(arrow_up, [["points", "17,19 17,17 13,17 20,11 27,17 23,17 23,19"],
  98. ["id", "pytplir_arrow_up"]]);
  99. setAttributes(arrow_down, [["points", "17,21 17,23 13,23 20,29 27,23 23,23 23,21"],
  100. ["id", "pytplir_arrow_down"]]);
  101. setAttributes(btn_svg, [["xmlns", svgNS],
  102. ["viewbox", "0 0 40 40"],
  103. ["width", "40"],
  104. ["height", "40"],
  105. ["style", "cursor: pointer; margin-left: 8px;"],
  106. ["id", "pytplir_btn"]]);
  107. setAttributes(tt_rect, [["x", "0"],
  108. ["y", "0"],
  109. ["rx", "2"],
  110. ["ry", "2"],
  111. ["width", "110"],
  112. ["height", "34"],
  113. ["fill", ttBGColor],
  114. ["fill-opacity", "0.9"]]);
  115. setAttributes(tt_text, [["x", "8"],
  116. ["y", "22"],
  117. ["font-family", "Roboto, Noto, sans-serif"],
  118. ["font-size", "13px"],
  119. ["fill", ttTextColor],
  120. ["style", "user-select:none;"]]);
  121. setAttributes(tt_svg_fadein, [["attributeType", "CSS"],
  122. ["attributeName", "opacity"],
  123. ["values", "0;1"],
  124. ["dur", "0.1s"],
  125. ["restart", "always"],
  126. ["repeatCount", "1"],
  127. ["begin", "indefinite"],
  128. ["id", "pytplir_tt_fadein"],
  129. ["fill", "freeze"]]);
  130. setAttributes(tt_svg_fadeout, [["attributeType", "CSS"],
  131. ["attributeName", "opacity"],
  132. ["values", "1;0"],
  133. ["dur", "0.1s"],
  134. ["restart", "always"],
  135. ["repeatCount", "1"],
  136. ["begin", "indefinite"],
  137. ["id", "pytplir_tt_fadeout"],
  138. ["fill", "freeze"]]);
  139. const tt_svg_offset = "position:absolute; top:13px; left:-32px; z-index:100; opacity:0.0;";
  140. setAttributes(tt_svg, [["viewbox", "0 0 100 34"],
  141. ["xmlns", "http://www.w3.org/2000/svg"],
  142. ["width", "100"],
  143. ["height", "34"],
  144. ["style", "padding-left: 10px; fill:" + ttBGColor + "; " + tt_svg_offset],
  145. ["id", "pytplir_tt"]]);
  146. setAttributes(tt_div, [["style", "position:relative; width:0; height:0;"]]);
  147. setAttributes(btn_div, [["id", "pytplir_div"]]);
  148. tt_text.innerHTML = "Autoplay order";
  149. bg_circle.appendChild(bg_circle_anim);
  150. appendChildren(btn_svg, [bg_circle, arrow_up, arrow_down]);
  151. appendChildren(tt_svg, [tt_rect, tt_text, tt_svg_fadein, tt_svg_fadeout]);
  152. tt_div.appendChild(tt_svg);
  153. appendChildren(btn_div, [btn_svg, tt_div]);
  154. $(btn_svg).on("click", onButtonClick);
  155. $(btn_svg).on("click", function(){$(this).parent().find("#pytplir_bg_circle_anim")[0].beginElement();});
  156. $(btn_svg).on("mouseenter", function(){$(this).parent().find("#pytplir_tt_fadein")[0].beginElement();});
  157. $(btn_svg).on("mouseleave", function(){$(this).parent().find("#pytplir_tt_fadeout")[0].beginElement();});
  158.  
  159. init();
  160.  
  161. function setAttributes(node, attributeValuePairs) { // [["id", "example"], ["width","20"], ...]
  162. for (let attVal of attributeValuePairs){
  163. node.setAttribute(attVal[0], attVal[1]);
  164. }
  165. }
  166.  
  167. function appendChildren(node, childList) {
  168. for (let child of childList) {
  169. node.appendChild(child);
  170. }
  171. }
  172.  
  173. function init() {
  174. // the button needs to be re-added whenever the playlist is updated (e.g when a video is loaded or removed)
  175. function observerCallback(mutationList, observer) {
  176. debugLog("Observer triggered!")
  177. start();
  178. }
  179. const playlistObserver = new MutationObserver(observerCallback);
  180. const observerOptions = {subtree:true, childList:true, characterData:true};
  181. initObserver(playlistObserver, observerOptions);
  182. playPrevious = getCookie("pytplir_playPrevious");
  183. if (playPrevious === "") { // cookie has not been set yet
  184. playPrevious = false; // inital state
  185. setCookie("pytplir_playPrevious", playPrevious);
  186. }
  187.  
  188. start();
  189. }
  190.  
  191. function initObserver(observer, options) {
  192. try {
  193. observer.observe($(selectors.playlistVideos)[0], options);
  194. observer.observe($(selectors.playlistVideosMiniplayer)[0], options);
  195. } catch (e) {
  196. setTimeout(function(){initObserver(observer)}, 100);
  197. }
  198. }
  199.  
  200. function onButtonClick() { // toggle
  201. playPrevious = !playPrevious;
  202. setCookie("pytplir_playPrevious", playPrevious);
  203. updateButtonState();
  204. }
  205.  
  206. function addButton() { // Add button(s)
  207. debugLog("addButton start")
  208. withQuery(selectors.buttonLocation, "*", function(res) {
  209. res.each(function() {
  210. if (!$(this).find("#pytplir_div").length) {
  211. this.appendChild($(btn_div).clone(true)[0]);
  212. updateButtonState();
  213. debugLog("button added");
  214. }
  215. });
  216. });
  217. debugLog("addButton finish")
  218. }
  219.  
  220. function updateButtonState() {
  221. if (playPrevious) { // play previous video
  222. $("polygon[id=pytplir_arrow_up]").each(function() {
  223. this.setAttribute("style", "fill:" + activeColor);
  224. });
  225. $("polygon[id=pytplir_arrow_down]").each(function() {
  226. this.setAttribute("style", "fill:" + inactiveColor);
  227. });
  228. } else { // play next video
  229. $("polygon[id=pytplir_arrow_up]").each(function() {
  230. this.setAttribute("style", "fill:" + inactiveColor);
  231. });
  232. $("polygon[id=pytplir_arrow_down]").each(function() {
  233. this.setAttribute("style", "fill:" + activeColor);
  234. });
  235. }
  236. miniplayerActive = isMiniplayerActive();
  237. let ctx = miniplayerActive ? selectors.miniplayerDiv : selectors.content;
  238. $(ctx + " #pytplir_btn")[0].setAttribute("activated", playPrevious);
  239. debugLog($(ctx + " #pytplir_btn"));
  240. }
  241.  
  242. function start() { // Add button(s) and event listeners
  243. addButton();
  244. debugLog("playerListenersAdded = " + playerListenersAdded);
  245. if (!playerListenersAdded) {
  246. withQuery(selectors.player, ":visible", function(res) {
  247. player = res[0];
  248. player.addEventListener("timeupdate", checkTime);
  249. player.addEventListener("play", addButton); // ensure button is added
  250. playerListenersAdded = true;
  251. });
  252. }
  253. }
  254.  
  255. function withQuery(query, filter="*", onSuccess = function(r){}) {
  256. let res;
  257. if (filter == "*") {
  258. res = $(query);
  259. } else {
  260. res = $(query).filter(filter);
  261. }
  262. if (res.length) { // >= 1 result
  263. onSuccess(res);
  264. return res;
  265. } else { // not loaded yet => retry
  266. setTimeout(function(){withQuery(query, filter, onSuccess)});
  267. }
  268. }
  269.  
  270. function isMiniplayerActive() {
  271. // Youtube seems to change this quite often, and due to A/B testing all of them need to be checked
  272. let miniplayer_attributes = ["miniplayer-is-active", "miniplayer-active_", "miniplayer-active"];
  273. miniplayerActive = false;
  274. for (let attr of miniplayer_attributes) {
  275. miniplayerActive ||= ytdApp.hasAttribute(attr);
  276. }
  277. return miniplayerActive;
  278. }
  279.  
  280. function checkTime() {
  281. let miniplayerActive = isMiniplayerActive();
  282. let context = miniplayerActive ? selectors.miniplayerDiv : selectors.content;
  283. let buttonSelector = context + " " + selectors.buttonLocation + " #pytplir_div";
  284. let noButton = !$(buttonSelector).length;
  285. let playlistHeaderQuery = miniplayerActive ? $(selectors.playlistVideosMiniplayer).parent() : $(selectors.playlistVideos).parent();
  286. let playlistVisible = playlistHeaderQuery.length && playlistHeaderQuery.is(":visible");
  287.  
  288. // exit early when not watching a playlist
  289. if (!playlistVisible) {return;} // button not loaded
  290. else if (noButton) { // button was removed
  291. debugLog("failsafe: adding button");
  292. addButton();
  293. }
  294.  
  295. debugLog("checkTime: miniplayer: " + miniplayerActive +
  296. ", button == " + !noButton);
  297.  
  298. let timeLeft = player.duration - player.currentTime;
  299. let videoPlayer = $(selectors.videoPlayer)[0];
  300.  
  301. let redirectTime;
  302. let shuffleContext;
  303. if (miniplayerActive) {
  304. redirectTime = redirectWhenTimeLeft_miniplayer;
  305. shuffleContext = selectors.playlistButtonsMiniplayer;
  306. } else {
  307. redirectTime = redirectWhenTimeLeft;
  308. shuffleContext = selectors.playlistButtons;
  309. }
  310.  
  311. if (!shuffle || (miniplayerActive != miniplayerFlag)) { // wysiwyg
  312. shuffle = $(shuffleContext + " " + selectors.shuffleButtonActive).parents("button[aria-pressed]");
  313. if (!shuffle.length) { // shuffle not activated or new UI has not been pushed to the user yet
  314. shuffle = $(shuffleContext + " " + selectors.shuffleButtonInactive).parents("button[aria-pressed]");
  315. if (!shuffle.length) { // new UI not pushed to user
  316. shuffle = $(selectors.shuffleButtonLegacy).filter(":visible").parents("button[aria-pressed]");
  317. }
  318. }
  319. shuffle = shuffle[0];
  320. miniplayerFlag = miniplayerActive;
  321. }
  322. try {videoPlayer.classList.contains("ad-showing");} // ensure it will work below
  323. catch (TypeError) { // video player undefined
  324. return;
  325. }
  326.  
  327. let shuffleEnabled;
  328. try {
  329. shuffleEnabled = strToBool(shuffle.attributes["aria-pressed"].nodeValue);
  330. } catch (TypeError) { // e.g. when using Queues
  331. shuffleEnabled = false;
  332. }
  333. if (timeLeft < redirectTime && !redirectFlag && playPrevious && !shuffleEnabled && !player.hasAttribute("loop")
  334. && !videoPlayer.classList.contains("ad-showing")) {
  335. // attempt to prevent the default redirect from triggering
  336. player.pause();
  337. player.currentTime -= 2;
  338.  
  339. if (getVidNum()[0] !== "1") {
  340. redirectFlag = true;
  341. redirect();
  342. setTimeout(function() {redirectFlag = false;}, 1000);
  343. }
  344. }
  345. }
  346.  
  347. function getVidNum() { // returns string array [current, total], e.g "32 / 152" => ["32", "152"]
  348. let vidNum;
  349. if (ytdApp.hasAttribute("miniplayer-active") || ytdApp.hasAttribute("miniplayer-active_")) {
  350. vidNum = $(selectors.playlistVideosMiniplayer);
  351. } else {
  352. vidNum = $(selectors.playlistVideos);
  353. }
  354. // the desired element is hidden; to distinguish from
  355. // other hidden elements, check parent's visibility
  356. vidNum = vidNum.filter(function(){
  357. return $(this).parent().is(":visible");
  358. })[0].innerText;
  359.  
  360. return vidNum.split(" / ");
  361. }
  362.  
  363. function redirect() {
  364. let previousURL = getPreviousURL();
  365. if (previousURL) {
  366. previousURL.click();
  367. }
  368. }
  369.  
  370. function getPreviousURL(){ // returns <a> element
  371. let elem;
  372. if (ytdApp.hasAttribute("miniplayer-active") || ytdApp.hasAttribute("miniplayer-active_")) { // avoid being forced out of miniplayer mode on video load
  373. elem = $(selectors.miniplayerDiv).find(selectors.playlistCurrentVideo).prev();
  374. } else {
  375. elem = $(selectors.content).find(selectors.playlistCurrentVideo).prev();
  376. }
  377.  
  378. let ts;
  379. if (skipUnplayable) {
  380. ts = $(elem).find(selectors.timestamp);
  381. if (ts.length) {ts = ts[0].innerText; }
  382. }
  383. while (!elem.find("#unplayableText").prop("hidden") ||
  384. (skipUnplayable && typeof(ts) == "string" && !ts.includes(":"))) { // while an unplayable (e.g. private) video is selected
  385. elem = elem.prev();
  386. if (!elem.length) return null; // first video in playlist
  387. if (skipUnplayable) {
  388. ts = $(elem).find(selectors.timestamp);
  389. if (ts.length) { ts = ts[0].innerText; }
  390. }
  391. }
  392. return elem.children()[0];
  393. }
  394.  
  395. function strToBool(str) {
  396. return str.toLowerCase() == "true";
  397. }
  398.  
  399. function debugLog(...args) {
  400. if (debug) {
  401. args.unshift("pytplir:");
  402. console.log.apply(this, args);
  403. }
  404. }
  405.  
  406. // adapted from https://www.w3schools.com/js/js_cookies.asp
  407. function setCookie(cname, cvalue) {
  408. document.cookie = cname + "=" + cvalue + ";sameSite=lax;path=www.youtube.com/watch";
  409. }
  410.  
  411. function getCookie(cname) {
  412. let name = cname + "=";
  413. let decodedCookie = decodeURIComponent(document.cookie);
  414. let ca = decodedCookie.split(';');
  415. for(let i = 0; i <ca.length; i++) {
  416. let c = ca[i];
  417. while (c.charAt(0) == ' ') {
  418. c = c.substring(1);
  419. }
  420. if (c.indexOf(name) == 0) {
  421. let x = c.substring(name.length, c.length);
  422. return strToBool(x);
  423. }
  424. }
  425. return "";
  426. }
  427. });
  428. })();
  429. /*eslint-env jquery*/ // stop eslint from showing "'$' is not defined" warnings

QingJ © 2025

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