TwitchVODEnhancer

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

// ==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);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址