Play Youtube playlist in reverse order

Adds button for loading the previous video in a YT playlist

目前為 2021-01-06 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Play Youtube playlist in reverse order
// @namespace    https://github.com/Dragosarus/Userscripts/
// @version      6.2
// @description  Adds button for loading the previous video in a YT playlist
// @author       Dragosarus
// @match        http://www.youtube.com/*
// @match        https://www.youtube.com/*
// @grant        none
// @require      http://code.jquery.com/jquery-latest.js
// @noframes
// ==/UserScript==

// Cookies (current session):
// pytplir_playPrevious - saves the button state between loads

/* NOTES:
 *    - If the button is not displayed (but the script is running), pause and unpause the video.
 *    - If it still does not appear, reload the page.
 *    - If the button is displayed but does not work properly/consistently, increase the value of redirectWhenTimeLeft.
*/

(function() {
    'use strict';
    $(document).ready(function() {
        // Determines when to load the next video.
        // Increase these if the redirect does not work as intended (i.e. fails to override Youtube's redirect),
        // Decreasing these will let you see more of the video before it redirects, but the redirect might stop working (consistently)
        var redirectWhenTimeLeft = 0.3; // seconds before the end of the video
        var redirectWhenTimeLeft_miniplayer = 0.6;
        var skipPremiere = true; // Skip videos that have not been premiered yet

        var activeColor = "rgb(64,166,255)";
        var inactiveColor = "rgb(144,144,144)";
        var circleColor = "rgb(144,144,144)";
        var ttBGColor = "rgb(100,100,100)";
        var ttTextColor = "rgb(237,240,243)";

        var player;
        var playPrevious;
        var ytdApp = $("ytd-app")[0];
        var redirectFlag = false;
        var vidNum; // string
        var shuffle;
        var miniplayerFlag = false; // keep track of switches between miniplayer and normal mode
        var playerListenersAdded = false;
        var buttonFailsafe = false; // used to readd button if it is removed immediately after being added

        // create button
        var svgNS = "http://www.w3.org/2000/svg";
        var btn_div = document.createElement("div");
        var bg_circle = document.createElementNS(svgNS, "circle");
        var bg_circle_anim = document.createElementNS(svgNS, "animate");
        var arrow_up = document.createElementNS(svgNS, "polygon");
        var arrow_down = document.createElementNS(svgNS, "polygon");
        var btn_svg = document.createElementNS(svgNS, "svg");
        var tt_svg = document.createElementNS(svgNS, "svg");
        var tt_svg_fadein = document.createElementNS(svgNS, "animate");
        var tt_svg_fadeout = document.createElementNS(svgNS, "animate");
        var tt_rect = document.createElementNS(svgNS, "rect");
        var tt_text = document.createElementNS(svgNS, "text");
        var tt_div = document.createElement("div");

        setAttributes(bg_circle_anim, [["attributeName", "fill-opacity"],
                                       ["values", "0;0.1;0.2;0.1;0.0"],
                                       ["dur", "0.3s"],
                                       ["restart", "always"],
                                       ["repeatCount", "1"],
                                       ["begin", "indefinite"],
                                       ["id", "pytplir_bg_circle_anim"]]);
        setAttributes(bg_circle, [["cx", "20"],
                                  ["cy", "20"],
                                  ["r", "20"],
                                  ["fill", circleColor],
                                  ["fill-opacity", "0"]]);
        setAttributes(arrow_up, [["points", "17,19 17,17 13,17 20,11 27,17 23,17 23,19"],
                                 ["id", "pytplir_arrow_up"]]);
        setAttributes(arrow_down, [["points", "17,21 17,23 13,23 20,29 27,23 23,23 23,21"],
                                   ["id", "pytplir_arrow_down"]]);
        setAttributes(btn_svg, [["xmlns", svgNS],
                                 ["viewbox", "0 0 40 40"],
                                 ["width", "40"],
                                 ["height", "40"],
                                 ["style", "cursor: pointer; margin-left: 8px;"],
                                 ["id", "pytplir_btn"]]);
        setAttributes(tt_rect, [["x", "0"],
                                ["y", "0"],
                                ["rx", "2"],
                                ["ry", "2"],
                                ["width", "110"],
                                ["height", "34"],
                                ["fill", ttBGColor],
                                ["fill-opacity", "0.9"]]);
        setAttributes(tt_text, [["x", "8"],
                                ["y", "22"],
                                ["font-family", "Roboto, Noto, sans-serif"],
                                ["font-size", "13px"],
                                ["fill", ttTextColor],
                                ["style", "user-select:none;"]]);
        setAttributes(tt_svg_fadein, [["attributeType", "CSS"],
                                      ["attributeName", "opacity"],
                                      ["values", "0;1"],
                                      ["dur", "0.1s"],
                                      ["restart", "always"],
                                      ["repeatCount", "1"],
                                      ["begin", "indefinite"],
                                      ["id", "pytplir_tt_fadein"],
                                      ["fill", "freeze"]]);
        setAttributes(tt_svg_fadeout, [["attributeType", "CSS"],
                                       ["attributeName", "opacity"],
                                       ["values", "1;0"],
                                       ["dur", "0.1s"],
                                       ["restart", "always"],
                                       ["repeatCount", "1"],
                                       ["begin", "indefinite"],
                                       ["id", "pytplir_tt_fadeout"],
                                       ["fill", "freeze"]]);
        var tt_svg_offset = "position:absolute; top:13px; left:-32px; z-index:100; opacity:0.0;";
        setAttributes(tt_svg, [["viewbox", "0 0 100 34"],
                               ["xmlns", "http://www.w3.org/2000/svg"],
                               ["width", "100"],
                               ["height", "34"],
                               ["style", "padding-left: 10px; fill:" + ttBGColor + "; " + tt_svg_offset],
                               ["id", "pytplir_tt"]]);
        setAttributes(tt_div, [["style", "position:relative; width:0; height:0;"]]);
        setAttributes(btn_div, [["id", "pytplir_div"]]);
        tt_text.innerHTML = "Autoplay order";
        bg_circle.appendChild(bg_circle_anim);
        appendChildren(btn_svg, [bg_circle, arrow_up, arrow_down]);
        appendChildren(tt_svg, [tt_rect, tt_text, tt_svg_fadein, tt_svg_fadeout]);
        tt_div.appendChild(tt_svg);
        appendChildren(btn_div, [btn_svg, tt_div]);
        $(btn_svg).on("click", onButtonClick);
        $(btn_svg).on("click", function(){$(this).parent().find("#pytplir_bg_circle_anim")[0].beginElement();});
        $(btn_svg).on("mouseenter", function(){$(this).parent().find("#pytplir_tt_fadein")[0].beginElement();});
        $(btn_svg).on("mouseleave", function(){$(this).parent().find("#pytplir_tt_fadeout")[0].beginElement();});

        init();

        function setAttributes(node, attributeValuePairs) { // [["id", "example"], ["width","20"], ...]
            for (var attVal of attributeValuePairs){
                node.setAttribute(attVal[0], attVal[1]);
            }
        }

        function appendChildren(node, childList) {
            for (var child of childList) {
                node.appendChild(child);
            }
        }

        function init() {
            // the button needs to be re-added whenever the playlist is updated (e.g when a video is loaded or removed)
            function observerCallback(mutationList, observer) {
                start();
            }
            const playlistObserver = new MutationObserver(observerCallback);
            const observerOptions = {subtree:true, childList:true, characterData:true};
            initObserver(playlistObserver, observerOptions);
            playPrevious = getCookie("pytplir_playPrevious");
            if (playPrevious === "") { // cookie has not been set yet
                playPrevious = false; // inital state
                setCookie("pytplir_playPrevious", playPrevious);
            }

            start();
        }

        function initObserver(observer, options) {
            try {
                observer.observe($(".ytd-watch-flexy #playlist").find("#playlist-action-menu")[0], options);
                observer.observe($("ytd-miniplayer")[0], options);
            } catch (e) {
                setTimeout(function(){initObserver(observer)}, 100);
            }
        }

        function onButtonClick() { // toggle
            playPrevious = !playPrevious;
            setCookie("pytplir_playPrevious", playPrevious);
            updateButtonState();
        }

        function addButton() { // Add button(s)
            withQuery(".ytd-playlist-panel-renderer > div[id=top-level-buttons]", "*", function(res) {
                res.each(function() {
                    if (!$(this).find("#pytplir_div").length) {
                        this.appendChild($(btn_div).clone(true)[0]);
                        updateButtonState();
                        buttonFailsafe = true; // try to detect immediate removal
                    }
                });
            });
        }

        function updateButtonState() {
            if (playPrevious) { // play previous video
                $("polygon[id=pytplir_arrow_up]").each(function() {
                    this.setAttribute("style", "fill:" + activeColor);
                });
                $("polygon[id=pytplir_arrow_down]").each(function() {
                    this.setAttribute("style", "fill:" + inactiveColor);
                });
            } else { // play next video
                $("polygon[id=pytplir_arrow_up]").each(function() {
                    this.setAttribute("style", "fill:" + inactiveColor);
                });
                $("polygon[id=pytplir_arrow_down]").each(function() {
                    this.setAttribute("style", "fill:" + activeColor);
                });
            }
            $("#pytplir_btn")[0].setAttribute("activated", playPrevious);
        }

        function start() { // Add button(s) and event listeners
            addButton();
            if (!playerListenersAdded) {
                withQuery(".html5-main-video", ":visible", function(res) {
                    player = res[0];
                    player.addEventListener("timeupdate", checkTime);
                    player.addEventListener("play", addButton); // ensure button is added
                    playerListenersAdded = true;
                });
            }
        }

        function withQuery(query, filter="*", onSuccess = function(r){}) {
            var res;
            if (filter == "*") {
                res = $(query);
            } else {
                res = $(query).filter(filter);
            }
            if (res.length) { // >= 1 result
                onSuccess(res);
                return res;
            } else { // not loaded yet => retry
                setTimeout(function(){withQuery(query, filter, onSuccess)});
            }
        }

        function checkTime() {
            var noButton = !$("#pytplir_div").length;
            if (noButton && !buttonFailsafe) {return;} // button not loaded
            else if (noButton) { // button was removed
                addButton();
                if (!$("#pytplir_div").length) { // not playing from a playlist anymore
                    buttonFailsafe = false; // let non-playlist videos exit checkTime() early
                }
            }

            var timeLeft = player.duration - player.currentTime;
            var videoPlayer = $(".html5-video-player")[0];
            var miniplayerActive = ytdApp.hasAttribute("miniplayer-active_");
            if (!shuffle || (miniplayerActive != miniplayerFlag)) { // wysiwyg
                shuffle = $("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']").filter(":visible").parents("button[aria-pressed]")[0];
            }
            var miniplayerFlag = miniplayerActive;
            var shuffleEnabled = strToBool(shuffle.attributes["aria-pressed"].nodeValue);
            try {videoPlayer.classList.contains("ad-showing");} // ensure it will work below
            catch (TypeError) { // video player undefined
            	return;
            }

            var redirectTime;
            if (miniplayerActive) {
                redirectTime = redirectWhenTimeLeft_miniplayer;
            } else {
                redirectTime = redirectWhenTimeLeft;
            }

            if (timeLeft < redirectTime && !redirectFlag && playPrevious && !shuffleEnabled && !player.hasAttribute("loop") && !videoPlayer.classList.contains("ad-showing")) {
                // attempt to prevent the default redirect from triggering
                player.pause();
                player.currentTime -= 2;

                if (getVidNum()[0] != 1) {
                    redirectFlag = true;
                    redirect();
                    setTimeout(function() {redirectFlag = false;}, 1000);
                }
            }
        }

        function getVidNum() { // returns integer array [current, total], e.g "32 / 152" => [32, 152]
            var vidNum_tmp;
            if (ytdApp.hasAttribute("miniplayer-active_")) {
                vidNum_tmp = $("yt-formatted-string[id=owner-name]").children()[2].innerHTML;
            } else {
                vidNum_tmp = $("#publisher-container").find("span.index-message")[0].innerHTML;
            }
            return vidNum_tmp.split(" / ").map(x => parseInt(x));
        }

        function redirect() {
            var previousURL = getPreviousURL();
            if (previousURL) {
                previousURL.click();
            }
        }

        function getPreviousURL(){ // returns <a> element
            var elem;
            if (ytdApp.hasAttribute("miniplayer-active_")) { // avoid being forced out of miniplayer mode on video load
                elem = $("div.miniplayer").find("ytd-playlist-panel-video-renderer[selected]").prev();
            } else {
                elem = $("#content").find("ytd-playlist-panel-video-renderer[selected]").prev();
            }

            if (skipPremiere) {
                var ts = $(elem).find("span.ytd-thumbnail-overlay-time-status-renderer");
                if (ts.length) {ts = ts[0].innerHTML; }
            }

            while (!elem.find("#unplayableText").prop("hidden") || (skipPremiere && typeof(ts) == "string" && !ts.includes(":"))) { // while an unplayable (e.g. private) video is selected
                elem = elem.prev();
                if (skipPremiere) {
                    ts = $(elem).find("span.ytd-thumbnail-overlay-time-status-renderer");
                    if (ts.length) { ts = ts[0].innerHTML; }
                }
            }
            return elem.children()[0];
        }

        function strToBool(str) {
            return str.toLowerCase() == "true";
        }

        // adapted from https://www.w3schools.com/js/js_cookies.asp
        function setCookie(cname, cvalue) {
            document.cookie = cname + "=" + cvalue + ";sameSite=lax;path=www.youtube.com/watch";
        }

        function getCookie(cname) {
            var name = cname + "=";
            var decodedCookie = decodeURIComponent(document.cookie);
            var ca = decodedCookie.split(';');
            for(var i = 0; i <ca.length; i++) {
                var c = ca[i];
                while (c.charAt(0) == ' ') {
                c = c.substring(1);
                }
                if (c.indexOf(name) == 0) {
                    var x = c.substring(name.length, c.length);
                    return strToBool(x);
                }
            }
            return "";
        }
    });
})();
/*eslint-env jquery*/ // stop eslint from showing "'$' is not defined" warnings