Video Speed Buttons

Add speed buttons to any HTML5 <video> element. Comes with a loader for YouTube and Vimeo

// ==UserScript==
// @name         Video Speed Buttons
// @description  Add speed buttons to any HTML5 <video> element. Comes with a loader for YouTube and Vimeo
// @namespace    bradenscode
// @version      1.0.10
// @copyright    2017, Braden Best
// @run-at       document-end
// @author       Braden Best
// @grant        none
//
// @match        *://*.youtube.com/*
// @match        *://youtube.com/*
// @match        *://*.vimeo.com/*
// @match        *://vimeo.com/*
// ==/UserScript==

// To add a new site: add a @match above, and modify loader_data.container_candidates near the bottom

const CONTROLLER_VSB = 0; // normal controller (video speed buttons). Uses lots of loops to enforce speed
const CONTROLLER_VSC = 1; // alternative controller (video speed controller). Keyboard-only, minimalistic, no loops

const controller_type = CONTROLLER_VSB;
// change this to use the experimental CONTROLLER_VSC, which is keyboard-only
// and extremely minimalistic, but is also fast, lightweight on memory usage,
// and doesn't use any loops. Try it out, see if it works better for you.
// The controls are the same as VSB. + to increase the speed, - to decrease,
// * to reset to 1.

function video_speed_buttons(anchor, video_el){
    if(!anchor || !video_el)
        return null;

    const COLOR_SELECTED = "#FF5500",
        COLOR_NORMAL = "grey",
        BUTTON_SIZE = "120%",
        DEFAULT_SPEED = 1.0,
        LABEL_TEXT = "Video Speed: ",
        ALLOW_EXTERNAL_ACCESS = false;

    const BUTTON_TEMPLATES = [
        ["25%",    0.25],
        ["50%",    0.5],
        ["Normal", 1],
        ["1.5x",   1.5],
        ["2x",     2],
        ["3x",     3],
        ["4x",     4],
        ["8x",     8],
        ["16x",    16]
    ];

    const buttons = {
        head:      null,
        selected:  null,
        last:      null
    };

    const keyboard_controls = [
        ["-", "Speed Down", function(ev){
            if(is_comment_box(ev.target))
                return false;

            (buttons.selected || buttons.head)
                .getprev()
                .el
                .dispatchEvent(new MouseEvent("click"));
        }],
        ["+", "Speed Up", function(ev){
            if(is_comment_box(ev.target))
                return false;

            (buttons.selected || buttons.head)
                .getnext()
                .el
                .dispatchEvent(new MouseEvent("click"));
        }],
        ["*", "Reset Speed", function(ev){
            let selbtn = buttons.head;
            let result = null;

            if(is_comment_box(ev.target))
                return false;

            while(selbtn !== null && result === null)
                if(selbtn.speed === DEFAULT_SPEED)
                    result = selbtn;
                else
                    selbtn = selbtn.next;

            if(result === null)
                result = buttons.head;

            result.el.dispatchEvent(new MouseEvent("click"));
        }],
        ["?", "Show Help", function(ev){
            let infobox;

            if(is_comment_box(ev.target))
                return false;

            (infobox = Infobox(container))
                .log("Keyboard Controls (click to close)<br>");

            keyboard_controls.forEach(function([key, description]){
                infobox.log(`    [${key}]  ${description}<br>`);
            });
        }]
    ];

    const container = (function(){
        let div = document.createElement("div");
        let prev_node = null;

        div.className = "vsb-container";
        div.style.borderBottom = "1px solid #ccc";
        div.style.marginBottom = "10px";
        div.style.paddingBottom = "10px";
        div.appendChild(SpeedButtonLabel(LABEL_TEXT));

        BUTTON_TEMPLATES.forEach(function(button){
            let speedButton = SpeedButton(...button, div);

            if(buttons.head === null)
                buttons.head = speedButton;

            if(prev_node !== null){
                speedButton.prev = prev_node;
                prev_node.next = speedButton;
            }

            prev_node = speedButton;

            if(speedButton.speed == DEFAULT_SPEED)
                speedButton.select();
        });

        return div;
    })();

    function is_comment_box(el){
        const candidate = [
            ".comment-simplebox-text",
            "textarea"
        ].map(c => document.querySelector(c))
         .find(el => el !== null);

        if(candidate === null){
            logvsb("video_speed_buttons::is_comment_box", "no candidate for comment box. Assuming false.");
            return 0;
        }

        return el === candidate;
    }

    function Infobox(parent){
        let el = document.createElement("pre");

        el.style.font = "1em monospace";
        el.style.borderTop = "1px solid #ccc";
        el.style.marginTop = "10px";
        el.style.paddingTop = "10px";

        el.addEventListener("click", function(){
            parent.removeChild(el);
        });

        parent.appendChild(el);

        function log(msg){
            el.innerHTML += msg;
        }

        return {
            el,
            log
        };
    }

    let playbackRate_data = {
        rate: 1,
        video: null,
    };

    function setPlaybackRate(el, rate){
        if(el) {
            el.playbackRate = rate;
            playbackRate_data.rate = rate;
            playbackRate_data.video = el;
        }
        else
            logvsb("video_speed_buttons::setPlaybackRate", "video element is null or undefined", 1);
    }

    function SpeedButtonLabel(text){
        let el = document.createElement("span");

        el.innerHTML = text;
        el.style.marginRight = "10px";
        el.style.fontWeight = "bold";
        el.style.fontSize = BUTTON_SIZE;
        el.style.color = COLOR_NORMAL;

        return el;
    }

    function SpeedButton(text, speed, parent){
        let el = SpeedButtonLabel(text);
        let self;

        el.style.cursor = "pointer";

        el.addEventListener("click", function(){
            setPlaybackRate(video_el, speed);
            self.select();
        });

        parent.appendChild(el);

        function select(){
            if(buttons.last !== null)
                buttons.last.el.style.color = COLOR_NORMAL;

            buttons.last = self;
            buttons.selected = self;
            el.style.color = COLOR_SELECTED;
        }

        function getprev(){
            if(self.prev === null)
                return self;

            return buttons.selected = self.prev;
        }

        function getnext(){
            if(self.next === null)
                return self;

            return buttons.selected = self.next;
        }

        return self = {
            el,
            text,
            speed,
            prev:  null,
            next:  null,
            select,
            getprev,
            getnext
        };
    }

    function kill(){
        anchor.removeChild(container);
        document.body.removeEventListener("keydown", ev_keyboard);
    }

    function set_video_el(new_video_el){
        video_el = new_video_el;
    }

    function ev_keyboard(ev){
        let match = keyboard_controls.find(([key, unused, callback]) => key === ev.key);
        let callback = (match || {2: ()=>null})[2];

        callback(ev);
    }

    setPlaybackRate(video_el, DEFAULT_SPEED);
    anchor.insertBefore(container, anchor.firstChild);
    document.body.addEventListener("keydown", ev_keyboard);

    return {
        controls: keyboard_controls,
        buttons,
        kill,
        SpeedButton,
        Infobox,
        setPlaybackRate,
        is_comment_box,
        set_video_el,
        playbackRate_data,
        ALLOW_EXTERNAL_ACCESS,
    };
}

video_speed_buttons.from_query = function(anchor_q, video_q){
    return video_speed_buttons(
            document.querySelector(anchor_q),
            document.querySelector(video_q));
}

// Multi-purpose Loader (defaults to floating on top right)
const loader_data = {
    container_candidates: [
        // YouTube
        "div#above-the-fold",
        "div#title.style-scope.ytd-watch-metadata",
        "div#container.ytd-video-primary-info-renderer",
        "div#watch-header",
        "div#watch7-headline",
        "div#watch-headline-title",
        // Vimeo
        ".clip_info-wrapper",
    ],

    css_div: [
        "position:    fixed",
        "top:         0",
        "right:       0",
        "zIndex:      100000",
        "background:  rgba(0, 0, 0, 0.8)",
        "color:       #eeeeee",
        "padding:     10px"
    ].map(rule => rule.split(/: */)),

    css_vsb_container: [
        "borderBottom:    none",
        "marginBottom:    0",
        "paddingBottom:   0",
    ].map(rule => rule.split(/: */))
};

function logvsb(where, msg, lvl = 0){
    let logf = (["info", "error"])[lvl];

    console[logf](`[vsb::${where}] ${msg}`);
}

function loader_loop(){
    let vsbc = () => document.querySelector(".vsb-container");
    let candidate;
    let default_candidate;
    let vsb_handle;

    if(vsbc() !== null)
        return;

    candidate = loader_data
        .container_candidates
        .map(candidate => document.querySelector(candidate))
        .find(candidate => candidate !== null);

    default_candidate = (function(){
        let el = document.createElement("div");

        loader_data.css_div.forEach(function([name, value]){
            el.style[name] = value; });

        return el;
    }());

    vsb_handle = video_speed_buttons(candidate || default_candidate, document.querySelector("video"));

    if(candidate === null){
        logvsb("loader_loop", "no candidates for title section. Defaulting to top of page.");
        document.body.appendChild(default_candidate);

        loader_data.css_vsb_container.forEach(function([name, value]){
            vsbc().style[name] = value;
        });
    }

    // ugly hack to address vimeo automatically resetting the speed
    vsb_handle.enforcer_loop_iid = setInterval(function(){
        let prdata = vsb_handle.playbackRate_data;

        if (prdata.video !== null)
            prdata.video.playbackRate = prdata.rate;
    }, 500);

    if(vsb_handle.ALLOW_EXTERNAL_ACCESS)
        window.vsb = vsb_handle;
}

const vsc = {
    name:     "Video Speed Controller",
    getvideo: _ => document.querySelector("video"), // Yep, it's really that simple.
    rates:    [0.25, 0.5, 1, 1.5, 2, 3, 4, 8, 16],
    selrate:  2,
    ev_keydown: function(ev) {
        let change = 0;

        if (vsc.getvideo() === null)
            return true;

        if (ev.key === "+")
            change = +1;

        if (ev.key === "-")
            change = -1;

        if (ev.key === "*")
            change = -(vsc.selrate - 2);

        vsc.selrate = (vsc.rates.length + vsc.selrate + change) % vsc.rates.length;
        vsc.getvideo().playbackRate = vsc.rates[vsc.selrate];
        console.log(`[${vsc.name}] Speed set to ${vsc.rates[vsc.selrate]}`);
    }
};

if (controller_type === CONTROLLER_VSB) {
    setInterval(function(){
        if(document.readyState === "complete")
            setTimeout(loader_loop, 1000);
    }, 1000); // Blame YouTube for this
}
else if (controller_type === CONTROLLER_VSC) {
    document.body.addEventListener("keydown", vsc.ev_keydown);
    console.clear();
    console.log(`[${vsc.name}] loaded`);
}