TwitchVODEnhancer

Find the most interesting moments in Twitch.tv Videos (VODs).

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         TwitchVODEnhancer
// @author       sooqua
// @namespace    https://github.com/sooqua/
// @version      0.4
// @match        *://*.twitch.tv/*
// @run-at       document-start
// @grant        GM_addStyle
// @description Find the most interesting moments in Twitch.tv Videos (VODs).
// ==/UserScript==
(function() {
    'use strict';

    const client_id = 'ENTER_YOUR_CLIENT_ID',
        canvas_width = 2500,
        canvas_height = 1,
        slider_height = 2.6,
        slider_height_unit = 'em',
        step = 60000, // msec.
        auto_zoom = 1; // width of one step (%), non-zero values override the 'zoom' value
    let zoom = 3;
    const gradient = [
        [
            0,
            [0, 0, 0]
        ],
        [
            25,
            [60, 100, 90]
        ],
        [
            30,
            [132, 220, 198]
        ],
        [
            33,
            [165, 255, 214]
        ],
        [
            35,
            [255, 222, 158]
        ],
        [
            85,
            [255, 166, 158]
        ],
        [
            100,
            [255, 104, 107]
        ]
    ];
    const slider_half_height = slider_height / 2;

    let steps_data_mc = [],
        steps_data_ts = [];

    let observer;

    async function init() {
        await initOn(document);
        observer = new MutationObserver(function(mutations) {
            mutations.forEach(function(mutation) {
                mutation.addedNodes.forEach(async function(node) {
                    if (node instanceof HTMLElement) {
                        await initOn(node);
                    }
                });
            });
        });
        observer.observe(document.body, {childList: true, subtree: true});
    }

    async function initOn(base) {
        let slider = base.querySelector('.js-player-slider');
        if (!slider) return;
        let slider_handle = base.querySelector('.ui-slider-handle');
        if (!slider_handle) return;
        observer.disconnect();

        let vid_id = /twitch.tv\/videos\/(\d+)/.exec(window.location.href)[1];

        let r = await getJson('https://api.twitch.tv/kraken/videos/' + vid_id + '?client_id=' + client_id),
            vid_start = new Date(r.recorded_at).getTime(),
            vid_length = r.length * 1000,
            vid_end = vid_start + vid_length,
            step_width = Math.round(step / vid_length * canvas_width);

        if (auto_zoom) {
            zoom = (auto_zoom / (step_width / canvas_width * 100)).clamp(1, 100);
        }

        GM_addStyle(`
        .player-seek {
            top: 0px !important;
        }
        .canvasWrapper {
            transform: translateZ(0) !important;
            overflow: hidden !important;
        }
        .js-player-slider:before {
            display: none !important;
        }
        .js-player-slider > .ui-slider-range {
            pointer-events: none !important;
            z-index: 1 !important;
            background: rgba(169, 145, 212, .5) !important;
            height: ${slider_height + slider_height_unit} !important;
            top: 0px !important;
            transition: initial !important;
        }
        .js-player-slider > .ui-slider-handle {
            pointer-events: none !important;
            width: .1em !important;
            height: ${slider_height + slider_height_unit} !important;
            background: black !important;
            border: .1em dotted white !important;
            margin-left: 0em !important;
            top: 0em !important;
            border-radius: initial !important;
            transition: initial !important;
        }
        .player-slider--roundhandle .ui-slider-handle:before {
            display: none !important;
        }
        .player-slider__popup-container {
            box-shadow: none !important;
            background: hsla(0,0%,0%,.5) !important;
        }
        .player-slider__muted-segments {
            pointer-events: none !important;
            height: ${slider_half_height + slider_height_unit} !important;
            top: ${slider_half_height + slider_height_unit} !important;
        }
        .player-slider__muted {
            pointer-events: none !important;
            height: ${slider_half_height + slider_height_unit} !important;
        }
        .sliderCanvas:hover {
            transform: scale(${zoom}, 1) !important;
        }`);

        let wrapper = document.createElement('div');
        wrapper.className = 'canvasWrapper';

        let c = document.createElement('canvas');
        c.className = 'sliderCanvas';
        c.width = canvas_width;
        c.height = canvas_height;
        c.style.width = '100%';
        c.style.height = slider_height + slider_height_unit;

        wrapper.appendChild(c);
        slider.appendChild(wrapper);

        let sheet;
        c.addEventListener('mousemove', function(e) {
            let r = wrapper.getBoundingClientRect(),
                m = (e.pageX - r.left) / r.width * 100;
            c.style.transformOrigin = m + '% center 0px';
            let m_h = (parseFloat(slider_handle.style.left) * zoom - m * zoom + m).clamp(0, 100);

            let s = `
            .ui-slider-handle {
                left: ${m_h}% !important;
            }
            .ui-slider-range {
                width: ${m_h}% !important;
            }`;

            let muted_bars = document.querySelectorAll('.player-slider__muted');
            for (let i = 0, l = muted_bars.length; i < l; ++i) {
                let m_b = (parseFloat(muted_bars[i].style.left) * zoom - m * zoom + m).clamp(0, 100);
                s += `
                .js-muted-segments-container > span:nth-child(${i + 1}) {
                    left: ${m_b}% !important;
                    transform: scale(${zoom}, 1) !important;
                    transform-origin: left !important;
                }`;
            }

            sheet = setStyle(s, sheet);
        });
        c.addEventListener('mouseout', function() {
            sheet = setStyle('', sheet);
        });

        let last_step_ts = vid_start,
            curr_step_mc = 0,
            ctx = c.getContext('2d');
        ctx.fillStyle = 'rgba(0, 0, 0, .5)';
        ctx.fillRect(0, 0, canvas_width, canvas_height);
        for (let ts = vid_start; ts < vid_end; ts += 30000) {
            r = await getJson('https://rechat.twitch.tv/rechat-messages?video_id=v' + vid_id + '&start=' + Math.round(ts / 1000));
            if (r.data.length === 0) {
                continue;
            }

            for (let i = 0; i < r.data.length; i++) {
                curr_step_mc++;
                let curr_msg_ts = r.data[i].attributes.timestamp;
                if (curr_msg_ts - last_step_ts >= step) {
                    steps_data_ts.push(curr_msg_ts);
                    steps_data_mc.push(curr_step_mc);
                    curr_step_mc = 0;

                    let steps_data_mc_max = Math.max(...steps_data_mc);
                    if (steps_data_mc_max <= 0) continue;

                    for (let i = 0, l = steps_data_mc.length; i < l; ++i) {
                        let pos = ((steps_data_ts[i] - vid_start) / (vid_end - vid_start)).clamp(0, 1),
                            int = (steps_data_mc[i] / steps_data_mc_max * 100).clamp(1, 100),
                            col = pickGradientColor(int, gradient);
                        ctx.fillStyle = 'rgb(' + col.join() + ')';
                        ctx.fillRect(Math.round(pos * canvas_width) - step_width, 0, step_width, canvas_height);
                    }

                    last_step_ts = curr_msg_ts;
                }
            }
        }
    }
    
    function getJson(url) {
        return new Promise(function(resolve) {
            let xhr = new XMLHttpRequest();
            xhr.addEventListener('load', function() { resolve(JSON.parse(this.responseText)); });
            xhr.open('GET', url,);
            xhr.send();
        });
    }

    function pickGradientColor(position, gradient) {
        let color_range = [];
        for (let i = 0; i < gradient.length; i++) {
            if (position<=gradient[i][0]) {
                color_range = [i-1,i];
                break;
            }
        }

        //Get the two closest colors
        let first_color = gradient[color_range[0]][1],
            second_color = gradient[color_range[1]][1];

        //Calculate ratio between the two closest colors
        let first_color_x = gradient[color_range[0]][0]/100,
            second_color_x = gradient[color_range[1]][0]/100-first_color_x,
            slider_x = position/100-first_color_x,
            ratio = slider_x/second_color_x;

        return pickHex( second_color,first_color, ratio );
    }

    function pickHex(color1, color2, weight) {
        let w = weight * 2 - 1,
            w1 = (w+1) / 2,
            w2 = 1 - w1;
        return [Math.round(color1[0] * w1 + color2[0] * w2),
            Math.round(color1[1] * w1 + color2[1] * w2),
            Math.round(color1[2] * w1 + color2[2] * w2)];
    }

    function setStyle(cssText) {
        let sheet = document.createElement('style');
        sheet.type = 'text/css';
        /* Optional */ window.customSheet = sheet;
        (document.head || document.getElementsByTagName('head')[0]).appendChild(sheet);
        return (setStyle = function(cssText, node) {
            if(!node || node.parentNode !== sheet)
                return sheet.appendChild(document.createTextNode(cssText));
            node.nodeValue = cssText;
            return node;
        })(cssText);
    }

    /**
    * Returns a number whose value is limited to the given range.
    *
    * Example: limit the output of this computation to between 0 and 255
    * (x * 255).clamp(0, 255)
    *
    * @param {Number} min The lower boundary of the output range
    * @param {Number} max The upper boundary of the output range
    * @returns A number in the range [min, max]
    * @type Number
    */
    Number.prototype.clamp = function(min, max) {
        return Math.min(Math.max(this, min), max);
    };

    document.addEventListener('DOMContentLoaded', init);
})();