Display remaining Youtube playlist time

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

目前為 2021-12-03 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Display remaining Youtube playlist time
// @namespace    https://github.com/Dragosarus/Userscripts/
// @version      4.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;

    /* true: The duration of the current video is ignored when determining the time left
     * false: The duration of the current video is added when determining the time left
    */
    const treatCurrentVideoAsWatched = false;

    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 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 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 time_processing = "..."; // shown after the script has started updating and before it has finished
    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 percentage_processing = "[...]"; // shown after the script has started updating and before it has finished


    const DOWN = false; // direction
    const UP = true; // direction
    let updateFlagTime = 0;
    let time_total_s = 0;
    let time_total_s_elapsed = 0; // stores duration of previous videos
    let direction_global = DOWN;
    let errorFlag = false;
    let incompleteFlag = false; // A playlist only displays the 199 previous+next entries in the playlist.
    let incompleteFlagR = false; // Used to determine if the percentage is accurate; checks the direction opposite to direction_global
    let 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": "#content #pytplir_btn", // https://greasyfork.org/en/scripts/404986-play-youtube-playlist-in-reverse-order
        "pytplir_btn_miniplayer": "div.miniplayer #pytplir_btn",
        "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() {
        miniplayerActive = $(selectors.ytd_app)[0].hasAttribute("miniplayer-active_") || $(selectors.ytd_app)[0].hasAttribute("miniplayer-active");
        if (!$(selectors.drypt_label).length || (miniplayerActive && !$(selectors.drypt_label_miniplayer).length)) {
            update();
        }

        let pytplir_btn;
        if (miniplayerActive) {
            pytplir_btn = document.querySelector(selectors.pytplir_btn_miniplayer);
        } else {
            pytplir_btn = document.querySelector(selectors.pytplir_btn);
        }

        if (pytplir_btn) {
            pytplir_btn.addEventListener("click", pytplirCallback);
        }
    }

    function update(force=false) {
        let 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");
        let playlistEntry = getCurrentEntry();
        if (!playlistEntry) {return;}

        display(true); // display message to indicate the script is processing the time left

        direction_global = getDirection();
        incompleteFlag = false;
        incompleteFlagR = false;
        if (treatCurrentVideoAsWatched) {
            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
                let next = getNextEntry(playlistEntry,!direction_global);
                if (next) {
                    addTime(next, !direction_global);
                }
            }
        }

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

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

    function getNextEntry(current, direction){
        let 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) {
        let vidNums = getVidNum();
        if (vidNums === undefined) { return; }
        let num;
        try {
            num = $(entry).find("#index")[0].innerHTML;
        } catch (e) { // most likely, the bottom of the playlist contains a message saying "n unavailable videos"
            let lastAvailableNum = $(entry).prev().find("#index")[0].innerHTML; // playlist index of the video before the message
            if (!(isNaN(parseInt(lastAvailableNum)))) { // last visible video in the list is not the last "available" one
                incompleteFlag = lastAvailableNum === vidNums[1];
            } else { // current video is the last "available" one, but there are unavailable videos
                incompleteFlag = false;
            }
            return;
        }
        let currentVideo = isNaN(parseInt(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 string array [current, total], e.g "32 / 152" => ["32","152"]
        let vidNum;
        if (miniplayerActive) {
            vidNum = $(selectors.vidNum_miniplayer).children()[2].innerHTML;
        } else {
            try {
                // the desired element is hidden; to distinguish from
                // other hidden elements, check parent's visibility
                vidNum = $(selectors.vidNum).filter(function(){
                    return $(this).parent().is(":visible");
                })[0].innerHTML;
            } catch (e) { // e.g. the user switched from one playlist to another
                return undefined;
            }
        }
        return vidNum.split(" / ");
    }

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

    function addTime(entry, direction) {
        let 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) {
        let unavailable = !$(item).find(selectors.unplayableText).prop("hidden");
        if (!unavailable) {
            let 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) {
        let 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(showLoading=false) {
        let timeString = "";
        if (showLoading) { timeString = time_processing; }
        else {
            let time = formatTime(time_total_s);
            if (time == "") {return;} // this is apparently possible
            if (showTime) {
                let time_before = incompleteFlag ? incompleteIndicator_time : completeIndicator_time; // e.g "(more than " or "( "
                timeString = time_before + time + time_after;
            }
        }

        let percentageString = "";
        if (showLoading) {percentageString = percentage_processing; }
        else if (showPercentage) {
            let missingData = incompleteFlag || incompleteFlagR; // due to large playlist
            let percentage_before = missingData ? incompleteIndicator_percentage : completeIndicator_percentage;
            let playlistTime = time_total_s + time_total_s_elapsed;
            let percentage;
            switch (percentageFormat) { // determine numerator
                case 0: // show % watched
                    percentage = time_total_s_elapsed;
                    break;
                case 1: // show % remaining
                    percentage = time_total_s;
                    break;
            }
            if (playlistTime != 0){
                percentage = 100 * percentage / playlistTime;
                if (!Number.isInteger(percentage)) {
                    percentage = percentage.toFixed(percentage_decimalPlaces);
                }
            } else { // treatCurrentVideoAsWatched == true and current video is first/last in playlist
                percentage = percentageFormat ? 0 : 100;
            }
            percentageString = percentage_before + percentage + percentage_after[percentageFormat];
        }

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

        } else { // miniplayer
            if (!$(selectors.drypt_label_miniplayer).length) {
                let 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) {
        let 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)
        let space = timeFormat1_spacing ? " " : "";
        let hh = Math.floor(time_total_s / 3600);
        let mm = Math.floor((time_total_s % 3600) / 60);
        let ss = time_total_s % 60;

        let 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)
        let hh = Math.floor(time_total_s / 3600);
        let mm = Math.floor((time_total_s % 3600) / 60);
        let ss = time_total_s % 60;

        let 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