Display remaining Youtube playlist time

Displays the sum of the lengths of the remaining videos in a playlist

当前为 2021-09-06 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Display remaining Youtube playlist time
// @namespace    https://github.com/Dragosarus/Userscripts/
// @version      3.0
// @description  Displays the sum of the lengths of the remaining videos in a playlist
// @author       Dragosarus
// @match        http://www.youtube.com/*
// @match        https://www.youtube.com/*
// @grant        none
// @noframes
// @require      http://code.jquery.com/jquery-latest.js
// ==/UserScript==

(function() {
    'use strict';

    /* Time formats:
     * 0: "x.xx hours" or "x.xx minutes" or "x seconds"
     * 1: xhxmxs (e.g 25m2s or 25m 2s)
     * 2: h:mm:ss (e.g 25:02 or 1:00:31 or 0:03)
    */
    const showTime = true;
    const timeFormat = 1;

    /* Percentage formats:
     * 0: % watched (e.g. [42% done])
     * 1: % remaining (e.g. [58% left])
    */
    const showPercentage = false; // e.g. [42% done] (will be shown to the right of the time)
    const percentageFormat = 0;

    const timeFormat0_decimalPlaces = 2;
    const timeFormat0_hourThreshold = 3600; // e.g "3.50" hours instead of "210.00 minutes" or "12600 seconds"
    const timeFormat0_minuteThreshold = 60; // e.g "2.50" minutes instead of "150.00 seconds"
    const timeFormat1_spacing = true; // e.g 1h 23m 2s instead of 1h23m2s
    const timeFormat1_forceFull = false; // e.g 0h3m2s instead of 3m2s, 3h0m50s instead of 3h50s, etc
    const timeFormat2_forceFull = false; // e.g 0:03:02 instead of 3:02

    const includeCurrentVideo = true; // whether the length of the current video should be included
    const before = " - "; // ::before
    const before_miniplayer = " • ";
    const updateCooldown = 2500; // limit how often update() is run (milliseconds)

    const time_after = " left) "; // e.g "15h 20m 15s left)"
    const incompleteIndicator_time = "(>"; // e.g "(>15h 20m 15s left)", for large playlists (> 200 videos)
    const completeIndicator_time = "("; // e.g "(15h 20m 15s left)"

    const percentageFormat0_after = "% done]";
    const percentageFormat1_after = "% left]";
    const incompleteIndicator_percentage = " [~"; // e.g "(10s left) [~20% done]", for large playlists (> 200 videos)
    const completeIndicator_percentage = " ["; // e.g "(10s left) [20% done]"
    const percentage_decimalPlaces = 1;
    const percentage_after = [percentageFormat0_after, percentageFormat1_after];


    const DOWN = false; // direction
    const UP = true; // direction
    var updateFlagTime = 0;
    var time_total_s = 0;
    var time_total_s_elapsed = 0; // stores duration of previous videos
    var direction_global = DOWN;
    var errorFlag = false;
    var incompleteFlag = false; // A playlist only displays the 199 previous+next entries in the playlist.
    var incompleteFlagR = false; // Used to determine if the percentage is accurate; checks the direction opposite to direction_global
    var miniplayerActive = false;

    const selectors = {
        "currentVideo": "#content ytd-playlist-panel-video-renderer[selected]",
        "currentVideo_miniplayer": "div.miniplayer ytd-playlist-panel-video-renderer[selected]",
        "drypt_label": "#drypt_label", // created by this script
        "drypt_label_miniplayer": "#drypt_label_miniplayer", // created by this script
        "playlistHeaderText": "div.index-message-wrapper",
        "pytplir_btn": "#pytplir_btn", // https://greasyfork.org/en/scripts/404986-play-youtube-playlist-in-reverse-order
        "timestamp": "span.ytd-thumbnail-overlay-time-status-renderer",
        "unplayableText": "#unplayableText",
        "vidCount": ".ytd-watch-flexy #playlist #publisher-container div yt-formatted-string",
        "vidCount_miniplayer": "yt-formatted-string[id=owner-name] :nth-child(3)",
        "vidNum": "#publisher-container span.index-message",
        "vidNum_miniplayer": "yt-formatted-string[id=owner-name]",
        "ytd_app": "ytd-app"
    };

    const playlistObserver = new MutationObserver(observerCallback);
    const pytplirObserver = new MutationObserver(pytplirCallback);
    const observerOptions = {attributes:true, characterData:true};

    initObservers(playlistObserver);
    setInterval(check, updateCooldown); // init; then ensure the pytplir button is detected correctly

    function observerCallback(mutationList, observer) {
       update();
    }
    function pytplirCallback(mutationList, observer) {
       update(true); // force update regardless of cooldown
    }

    function initObservers(observer) {
        try {
            observer.observe($(selectors.vidCount)[0], observerOptions);
            observer.observe($(selectors.vidCount_miniplayer)[0], observerOptions); // miniplayer
        } catch (e) {
            setTimeout(function(){initObservers(observer)},100);
        }
    }

    function check() {
        if (!$(selectors.drypt_label).length || (miniplayerActive && !$(selectors.drypt_label_miniplayer).length)) {
            update();
        }
        if ($(selectors.pytplir_btn).length) {
            pytplirObserver.observe($(selectors.pytplir_btn)[0],{attributes:true});
        }

    }

    function update(force=false) {
        var timeSinceUpdate = Date.now() - updateFlagTime;
        if (timeSinceUpdate < updateCooldown && !force) {
            setTimeout(update, updateCooldown - timeSinceUpdate);
            return;
        }

        updateFlagTime = Date.now();
        miniplayerActive = $(selectors.ytd_app)[0].hasAttribute("miniplayer-active_") || $(selectors.ytd_app)[0].hasAttribute("miniplayer-active");
        var playlistEntry = getCurrentEntry();
        if (!playlistEntry) {return;}

        direction_global = getDirection();
        incompleteFlag = false;
        incompleteFlagR = false;
        if (!includeCurrentVideo) {
            playlistEntry = getNextEntry(playlistEntry, direction_global);
        }

        time_total_s = 0;
        time_total_s_elapsed = 0;
        if (playlistEntry){
            addTime(playlistEntry, direction_global);
        }
        if (showPercentage) { // also need to sum the video durations in the other direction
            addTime(playlistEntry, !direction_global);
        }

        if (!errorFlag){
            display();
        } else {
            setTimeout(update,100);
            errorFlag = false;
        }
    }

    function getCurrentEntry(){ // returns <ytd-playlist-panel-video-renderer> element
        var elem;
        try {
            if (miniplayerActive) {
                return $(selectors.currentVideo_miniplayer)[0];
            } else {
                return $(selectors.currentVideo)[0];
            }
        } catch (e) {
            errorFlag = true;
        }
    }

    function getNextEntry(current, direction){
        var previous = current;
        if (direction) {
            current = $(current).prev();
        } else {
            current = $(current).next();
        }
        if (current.length) {
            if (!current.find(selectors.unplayableText).prop("hidden")) {
                return getNextEntry(current, direction);
            } else {
                return current[0];
            }
        } else {
            checkIncomplete(previous, direction);
            return undefined;
        }
    }

    function checkIncomplete(entry, direction) {
        var vidNums = getVidNum();
        var num = parseInt($(entry).find("#index")[0].innerHTML);
        var currentVideo = isNaN(num) // ▶ instead of number
        if (!currentVideo){ // current video is neither the first nor the last video in the playlist
            if (direction == direction_global) {
                incompleteFlag = (direction_global == DOWN && num != vidNums[1]) || (direction_global == UP && num != 1);
            } else {
                incompleteFlagR = (direction_global == UP && num != vidNums[1]) || (direction_global == DOWN && num != 1);
            }
        }
    }

    function getVidNum() { // returns integer array [current, total], e.g "32 / 152" => [32,152]
        var vidNum_tmp;
        if (miniplayerActive) {
            vidNum_tmp = $(selectors.vidNum_miniplayer).children()[2].innerHTML;
        } else {
            vidNum_tmp = $(selectors.vidNum)[0].innerHTML;
        }
        return vidNum_tmp.split(" / ").map(x => parseInt(x));
    }

    function getDirection(){ // Compatible with https://greasyfork.org/en/scripts/404986-play-youtube-playlist-in-reverse-order
        var pytplir_btn = $(selectors.pytplir_btn);
        if (!pytplir_btn.length) {
            return DOWN;
        } else {
            return pytplir_btn.attr("activated") == "true" ? UP : DOWN;
        }
    }

    function addTime(entry, direction) {
        var time_raw = getTime(entry);
        if (time_raw != "-1") {
            if (direction == direction_global){
                time_total_s += hmsToSecondsOnly(time_raw);
            } else { // in order to calculate % done/remaining
                time_total_s_elapsed += hmsToSecondsOnly(time_raw);
            }
            entry = getNextEntry(entry, direction);
            if (entry) {
                addTime(entry, direction);
            }
        } else {
            errorFlag = true;
        }
    }

    function getTime(item) {
        var unavailable = !$(item).find(selectors.unplayableText).prop("hidden");
        if (!unavailable) {
            var time = $(item).find(selectors.timestamp);
            if (!time.length) {// timestamp has not been loaded yet
                return "-1";
            } else {
                return $.trim(time[0].innerHTML);
            }
        } else { // unwatchable video => no timestamp
            return "0";
        }
    }

    // https://stackoverflow.com/questions/9640266/convert-hhmmss-string-to-seconds-only-in-javascript
    function hmsToSecondsOnly(str) {
        var p = str.split(':'),
            s = 0, m = 1;

        while (p.length > 0) {
            s += m * parseInt(p.pop(), 10);
            m *= 60;
        }

        if (isNaN(s)) { // Likely caused by premiere video
            return 0;
        }

        return s;
    }

    function display() {
        var time = formatTime(time_total_s);
        if (time == "") {return;} // this is apparently possible
        var timeString = "";
        if (showTime) {
            var time_before = incompleteFlag ? incompleteIndicator_time : completeIndicator_time; // e.g "(more than " or "( "
            timeString = time_before + time + time_after;
        }

        var percentageString = "";
        if (showPercentage) {
            var missingData = incompleteFlag || incompleteFlagR; // due to large playlist
            var percentage_before = missingData ? incompleteIndicator_percentage : completeIndicator_percentage;
            var playlistTime = time_total_s + time_total_s_elapsed;
            var percentage;
            switch (percentageFormat) { // determine numerator
                case 0: // show % watched
                    percentage = time_total_s_elapsed;
                    break;
                case 1: // show % remaining
                    percentage = time_total_s;
                    break;
            }
            percentage = (100 * percentage / playlistTime).toFixed(percentage_decimalPlaces);
            percentageString = percentage_before + percentage + percentage_after[percentageFormat];
        }

        if (!miniplayerActive) {
            if (!$(selectors.drypt_label).length) {
                var label = document.createElement("a");
                var textColor = "rgb(237,240,243)";
                label.setAttribute("font-family","Roboto, Noto, sans-serif");
                label.setAttribute("font-size","13px");
                label.setAttribute("fill",textColor);
                label.setAttribute("id","drypt_label");
                $(selectors.playlistHeaderText)[0].appendChild(label);
            }
            $(selectors.drypt_label)[0].innerHTML = before + timeString + percentageString;

        } else { // miniplayer
            if (!$(selectors.drypt_label_miniplayer).length) {
                var label_miniplayer = document.createElement("a");
                label_miniplayer.setAttribute("font-family","Roboto, Noto, sans-serif");
                label_miniplayer.setAttribute("font-size","13px");
                label_miniplayer.setAttribute("fill",textColor);
                label_miniplayer.setAttribute("id","drypt_label_miniplayer");
                $(selectors.vidNum_miniplayer)[0].appendChild(label_miniplayer);
            }
            $(selectors.drypt_label_miniplayer)[0].innerHTML = before_miniplayer + timeString + percentageString;
        }
        incompleteFlag = false;
    }

    function formatTime(time_total_s) {
        var formats = [formatTime0, formatTime1, formatTime2];
        if (timeFormat > formats.length || timeFormat < 0) {
            timeFormat = 0;
        }
        return formats[timeFormat](time_total_s);
    }

    function formatTime0(time_total_s) { // "x.xx hours" OR "x.xx minutes" OR "x seconds"
        if (time_total_s >= timeFormat0_hourThreshold) {
            return (time_total_s / 3600).toFixed(timeFormat0_decimalPlaces) + " hours";
        } else if (time_total_s >= timeFormat0_minuteThreshold) {
            return (time_total_s / 60).toFixed(timeFormat0_decimalPlaces) + " minutes";
        } else {
            return time_total_s + " seconds";
        }
    }

    function formatTime1(time_total_s) { // xhxmxs (e.g 25m2s or 25m 2s)
        var space = timeFormat1_spacing ? " " : "";
        var hh = Math.floor(time_total_s / 3600);
        var mm = Math.floor((time_total_s % 3600) / 60);
        var ss = time_total_s % 60;

        var text = "";
        if (hh > 0 || timeFormat1_forceFull) {
            text += hh + "h" + space;
        };
        if (mm > 0 || timeFormat1_forceFull) {
            text += mm + "m" + space;
        };
        if (ss > 0 || timeFormat1_forceFull || !time_total_s) {
            text += ss + "s";
        };
        return text;
    }

    function formatTime2(time_total_s) { // h:mm:ss (e.g 25:02 or 1:00:31 or 0:03)
        var hh = Math.floor(time_total_s / 3600);
        var mm = Math.floor((time_total_s % 3600) / 60);
        var ss = time_total_s % 60;

        var text = "";
        if (hh > 0 || timeFormat2_forceFull) {
            text += hh + ":";
        }
        if (hh > 0 && mm > 0) { // 1:01:22
            if (mm < 10) {text += "0";}
        }
        text += mm + ":";
        if (ss < 10) {text += "0"}
        text += ss;
        return text;
    }

})();
/*eslint-env jquery*/ // stop eslint from showing "'$' is not defined" warnings