Youtube outro skip

Set outro for any youtube channel and will automatically skip to next video when time is reached.

2020-10-09 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

// ==UserScript==
// @name         Youtube outro skip
// @namespace    http://tampermonkey.net/
// @version      1.7
// @description  Set outro for any youtube channel and will automatically skip to next video when time is reached.
// @author       Daile Alimo
// @match        https://www.youtube.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @require      http://code.jquery.com/jquery-latest.js
// ==/UserScript==

const version = 1.7;
const debug = false;

const log = function(s) {
    if (debug) {
        console.log(s);
    }
}

let forceDestroy = false;
const destroy = function(callback){
    //
    // make sure we have no events binded, in the case fn() was called by interval on URL change
    // this will ensure that we can create clean controls for the current playlist without accidentally
    // having events persisting in the background.
    //
    log("destroying..");
    if ($(".video-stream").unbind()) {log("unbinding .video-stream");}
    if ($("#set-outro").unbind()){log("unbinding #set-outro");}
    if ($("#outro-controls").remove()){log("removed controls");}
    if ($("#outro-bar").remove()){log("removed progressbar");}
    forceDestroy = false;
    callback();
}

// whenReady - keep checking the DOM until these given selectors are found
// invokes a callback function on complete that contains an object containing the
// JQuery element(s) for the given selectors accessable with aliases if given.
const whenReady = ({selectors = [], aliases = [], mutators = {}, callback = (selectors = {})}) => {
    log("whenReady called");
    let ready = {};
    let found = 0;
    for(let i in selectors){
        let $sel = $(selectors[i]);
        if ($sel.length) {
            let index = aliases[i] ? aliases[i]: i;
            if (mutators[index]) {
                log(index + " mutator found");
                ready[index] = mutators[index]($sel);
                log("mutator returned: " + ready[index]);
                if (ready[index]){
                    found++;
                }
            } else {
                log("no mutator, added raw selector: " + index);
                ready[index] = $sel;
                found++;
            }
        }
    }
    log(found + " out of given " + selectors.length + " selector(s) found");
    if (found === selectors.length) {
        return callback(ready);
    }
    setTimeout(function(){
        log("waiting for " + (selectors.length - found) + " selector(s)");
        whenReady({
            selectors: selectors,
            aliases: aliases,
            mutators: mutators,
            callback: callback
        });
    }, 500);
};
// validateChannel - ensure we get a channel name out of the channel name element
const validateChannel = (selector) => {
    let channel = selector.first().text();
    log("validating channel: " + channel);
    if (channel === "") {
        return false;
    }
    return channel;
}
//
const outroProgressBar_ID = "outro-bar";
// writeProgressBar
const setupProgressBar = (selector) => {
    destroyProgressBar();
    return selector.prepend(
        $("<div id='" + outroProgressBar_ID + "'>").addClass("ytp-load-progress").css({
            "left": "100%",
            "transform": "scaleX(0)",
        })
    );
}
const destroyProgressBar = () => {
    if($("#" + outroProgressBar_ID).remove()){log("removed progress bar");}
}
const updateProgressBar = (outro, duration) => {
    let progressBar = $("#" + outroProgressBar_ID)
    var fraction = outro / duration;
    var percent = fraction * 100;
    progressBar.css({
        "left": (100 - percent) + "%",
        "transform": "scaleX(" + fraction + ")",
        "background-color": "red",
    });
}
//
const controlUI_ID = "outro-controls";
const outroTime_ID = "outro-set";
const outroLen_ID = "outro-length";
const channelTxt_ID = "channel_txt";
const applyOutro_ID = "apply-outro";
const setupControls = (selector) => {
    destroyControls();
    return selector.prepend(
        $("<div id='" + controlUI_ID + "'>").append(
            $("<h3>Youtube Skip Outro Controller " + version + "</h3>")
        ).append(
            $("<input type='number' min='0' id='" + outroLen_ID + "' placeholder='loading channel'/>")
        ).append(
            $("<button id='" + applyOutro_ID + "'>apply</button>")
        ).append(
            $("<div><span id='channel_txt'>loading</span> outro set: <span id='" + outroTime_ID + "'>0</span> seconds</div>").css({
                "padding": "2px",
            })
        ).css({
            "margin": "2px",
            "textAlign": "right",
            "color": "#666666",
        })
    );
}
const destroyControls = () => {
    if($("#" + controlUI_ID).remove()){log("removed controls");}
}
const updateControls = ({placeholderTxt, channelTxt, outroTxt}) => {
    if (placeholderTxt) {
        $("#" + outroLen_ID).attr("placeholder", placeholderTxt);
    }
    if (channelTxt) {
        $("#" + channelTxt_ID).text(channelTxt);
    }
    if (outroTxt) {
        $("#" + outroTime_ID).text(outroTxt);
    }
}

(function(fn){
    "use strict";
    //
    //$(document).ready(function(){
    //    fn();
    //});
    //
    // detect page change hashchange not working
    // so check every 3 seconds if current URL matches URL we started with.
    // handle appropriately.
    //
    var l = document.URL;
    if (l.includes("watch")) {
        fn();
    }
    setInterval(function(){
        if (forceDestroy) {
            log("forced to destroy");
            destroy(function() {
                log("rebuilding..");
                fn();
            });
        }
        if (l != document.URL){
            l = document.URL;
            if (l === "https://www.youtube.com/") {
                // ignore home
                destroy(function(){
                   log("complete destruction");
                });
            } else if (l.includes("watch")) {
                log("channel changed");
                forceDestroy = true
            }
        }
    }, 3000);
})(function(){
    // ignore home
    if (document.URL === "https://www.youtube.com/"){
        log("ignoring home");
        return;
    }
    //
    whenReady({ //  .ytp-progress-list
        selectors: [".video-stream", "#primary > #primary-inner", ".ytp-progress-bar", "#meta-contents #text.ytd-channel-name,.ytp-ce-channel-title > a"],
        aliases: ["stream", "container", "progressBar", "channel"],
        mutators: {
            "container": setupControls,
            "progressBar": setupProgressBar,
            "channel": validateChannel,
        },
        callback: (selectors) => {
            //
            let channel = selectors.channel;
            let targetId = channel.split(" ").join("_");
            log("loaded channel: " + channel);
            //
            var loadedOutroSetInSeconds = GM_getValue(targetId) || 0;
            log("outro set: " + loadedOutroSetInSeconds);
            //
            updateControls({
                placeholderTxt: (loadedOutroSetInSeconds <= 0)? "using channel: " + channel: loadedOutroSetInSeconds,
                channelTxt: channel,
                outroTxt: loadedOutroSetInSeconds,
            });
            //
            //
            const bindToStream = function(){
                // hook video timeupdate, wait for outro and hit next button when time reached
                //
                log("binding events");
                let progressBarDone = false;
                var loadedOutroSetInSeconds = GM_getValue(targetId) || 0;
                log("outro set: " + loadedOutroSetInSeconds);
                //
                // set duration here and call writeProgressBars
                selectors.stream.unbind("timeupdate").on("timeupdate", function(e){
                    var currentTime = this.currentTime;
                    var duration = this.duration;
                    //
                    if (duration && !progressBarDone) {
                        progressBarDone = true;
                        updateProgressBar(loadedOutroSetInSeconds, duration);
                    }
                    //
                    if(currentTime >= duration - loadedOutroSetInSeconds){
                        // GM_setValue(targetId + "-duration", 0);
                        $(".ytp-next-button")[0].click();
                    }
                });
            };
            //
            // handle apply outro in seconds
            //
            log("bind to click");
            $("#" + applyOutro_ID).on("click", function(e){
                log("updating outro skip");
                var seconds = $("#" + outroLen_ID).val().toString();
                if(seconds && seconds != "" && parseInt(seconds) != NaN){
                    if (seconds < 0) {
                        seconds = 0;
                    }
                    // update the outro time on the outro controls
                    updateControls({
                        outroTxt: seconds
                    });
                    // save outro in local storage
                    GM_setValue(targetId, seconds);
                    bindToStream();
                }
            });
            bindToStream();
        },
    });
});