Sleazy Fork is available in English.

VHAddon

Blocks ViewHub popups and adds some extra spice.

// ==UserScript==
// @name            VHAddon
// @version         1.2.0
// @description     Blocks ViewHub popups and adds some extra spice.
// @license         GPL
// @match           https://viewhub.show/*
// @grant           none
// @run-at          document-start
// @namespace       https://greasyfork.org/users/1018597
// @require         https://greasyfork.org/scripts/28536-gm-config/code/GM_config.js?version=184529
// ==/UserScript==

(() => {
    "use strict";

    GM_config.init({
        id: "VHAddonConfig",
        title: "VHAddon Configuration (refresh after save)",
        fields: {
            "block_popups": {
                "label": "Hide popups on streamer page",
                "type": "checkbox",
                "default": true,
            },
            "quality_menu": {
                "label": "Enable video quality menu",
                "type": "checkbox",
                "default": true,
            },
            "lowest_start_quality": {
                "label": "Start videos with lowest quality if the quality menu feature is active",
                "type": "checkbox",
                "default": false,
            },
            "video_preview_type": {
                "label": "Show video preview in front page",
                "type": "radio",
                "options": ["never", "mouseover", "always"],
                "default": "always",
            },
            "blocked_users": {
                "label": "Comma separated list of users to hide from the front page",
                "type": "text",
                "size": 100,
            }
        }
    });

    let addon;

    class Importer {
        #load_cache = {};
        #require;

        constructor(require) {
            this.#require = require;
        }

        find_module_id(regex, name) {
            for (const chunk of window.webpackJsonp) {
                if (name && !chunk[0].includes(name)) {
                    continue;
                }
                const modules = chunk[1];
                for (const module_id in modules) {
                    const code = modules[module_id].toString();
                    if (code.match(regex)) {
                        return module_id;
                    }
                }
            }
        }

        must_find_module_id(regex, name) {
            const result = this.find_module_id(regex, name);
            if (!result) {
                throw new Error(`Could not find module matching ${regex}` + (name ? ` in chunk "${name}"` : ""));
            }
            return result;
        }

        module(id) {
            return this.#require(id).a;
        }

        module_es6(id) {
            return this.#require.n(this.#require(id));
        }

        async chunk(name) {
            if (this.#load_cache[name] === true) {
                return;
            }
            if (this.#load_cache[name] === undefined) {
                const self = this;
                this.#load_cache[name] = (async function () {
                    await self.#require.e(name);
                    self.#load_cache[name] = true;
                })();
            }
            await this.#load_cache[name];
        }
    }

    class HLSPlayer {
        static #hls_module = null;
        #internal = null;

        constructor(stream_key, target, level, handler) {
            if (!HLSPlayer.#hls_module) {
                throw new Error("Must call HLSPlayer.init and await on it before constructing an HLSPlayer");
            }
            this.#internal = new HLSPlayer.#hls_module.a({
                xhrSetup: function (xhr, url) {
                    xhr.open("GET", url + "?token=" + Date.now())
                }
            });
            const self = this;
            this.#internal.on("hlsManifestParsed", () => {
                if (!self.#internal) {
                    return; // we were destroyed before arriving here
                }
                if (handler && handler.levels) {
                    handler.levels.call(self.#internal, self.#internal.levels);
                }
                target.muted = "muted";
                target.play();
            });
            if (handler && handler.levelChanged) {
                this.#internal.on("hlsLevelSwitched", function (_, data) {
                    handler.levelChanged.call(self.#internal, data.level);
                });
            }
            if (level !== undefined) {
                this.#internal.startLevel = 0;
                this.#internal.currentLevel = 0;
            }
            this.#internal.attachMedia(target);
            this.#internal.loadSource(`https://c1565z2457.r-cdn.com/LiveApp/streams/${stream_key}_adaptive.m3u8`);
        }

        destroy() {
            if (this.#internal) {
                this.#internal.destroy();
                this.#internal = null;
            }
        }

        static async init() {
            if (HLSPlayer.#hls_module) {
                return; // already initialized
            }
            const importer = addon.importer;
            await importer.chunk("stream");
            const hls_module_id = importer.must_find_module_id(
                /\.\/src\/hls\.ts \*/,
                "stream",
            );
            HLSPlayer.#hls_module = importer.module_es6(hls_module_id);
            if (!HLSPlayer.#hls_module) {
                throw new Error("Failed to load hls module");
            }
        }
    }

    const hlspreview = {
        props: ["poster"],
        data: function () {
            return {};
        },
        beforeUnmount: function () {
            this.stop_preview();
            this.stop_observe();
            this.anchor = null;
        },
        mounted: function () {
            this.anchor = this.get_anchor();
            this.start_observer();
            if (GM_config.get("video_preview_type") === "always") {
                this.start_preview();
            }
        },
        methods: {
            is_mounted() {
                return !!this.anchor;
            },
            get_anchor() {
                const parent = this.$el.parentElement;
                if (parent && parent.tagName == "A") {
                    return parent;
                }
                throw new Error("parent element is not an anchor");
            },
            get_user() {
                const href = this.anchor.getAttribute("href");
                const match = href.match(/\/stream\/([0-9a-zA-Z_]+)/);
                if (match) {
                    return match[1];
                }
            },
            start_observer() {
                if (!this.is_mounted()) {
                    return;
                }
                const self = this;
                this.observer = new MutationObserver((mutations) => {
                    for (const mutation of mutations) {
                        if (mutation.type === "attributes" && mutation.attributeName === "href") {
                            self.restart_preview();
                        }
                    }
                });
                this.observer.observe(this.anchor, {
                    attributes: true
                });
            },
            stop_observer() {
                if (this.observer) {
                    this.observer.disconnect();
                    this.observer = null;
                }
            },
            restart_preview() {
                if (this.hls_player) {
                    this.stop_preview();
                    this.start_preview();
                }
            },
            async start_preview() {
                if (!this.is_mounted()) {
                    return;
                }
                const user = this.get_user();
                if (!user || VHAddon.is_blocked_user(user)) {
                    return;
                }
                const stream_key = await addon.get_stream_key(user);
                if (!stream_key || !this.is_mounted()) {
                    return;
                }
                await HLSPlayer.init();
                if (this.is_mounted() && !this.hls_player) {
                    this.hls_player = new HLSPlayer(stream_key, this.$el.querySelector("video"), 0);
                }
            },
            stop_preview() {
                if (this.hls_player) {
                    this.hls_player.destroy();
                    this.hls_player = null;
                }
            },
            on_hover(b) {
                if (GM_config.get("video_preview_type") !== "mouseover") {
                    return;
                }
                if (b) {
                    if (this.hls_player) {
                        return;
                    }
                    this.start_preview();
                } else {
                    if (!this.hls_player) {
                        return;
                    }
                    this.stop_preview();
                }
            },
        },
        render: function (createElement) {
            const self = this;
            return createElement(
                "div", {
                staticClass: "v-responsive",
                style: {
                    width: 320,
                },
                on: {
                    mouseenter: function () { self.on_hover(true); },
                    mouseleave: function () { self.on_hover(false); },
                }
            },
                [
                    createElement("div", {
                        staticClass: "v-responsive__sizer",
                        style: {
                            "padding-bottom": "56.25%",
                        }
                    }),

                    createElement("video", {
                        attrs: {
                            poster: self.poster,
                        },
                        style: {
                            position: "absolute",
                            top: 0,
                            left: 0,
                            "z-index": 0,
                            width: "100%",
                            height: "100%",
                            display: "block",
                        }
                    }),

                    createElement("div", {
                        staticClass: "v-responsive__content",
                        style: {
                            "z-index": 1,
                        },
                    }, this.$slots.default)
                ]);
        }
    };

    class Component {
        static get_name(component) {
            if (!component || !component.$vnode || typeof component.$vnode.tag !== "string") {
                return;
            }
            const match = component.$vnode.tag.match(/vue\-component\-\d+\-(.+)/);
            if (match) {
                return match[1];
            }
        }

        static hook_renderer(component, hook_cb) {
            function patch(renderer) {
                return function (tag, data, children, normalizationType) {
                    const result = hook_cb(component, renderer, tag, data, children, normalizationType);
                    if (result) {
                        tag = result.tag || tag;
                        data = result.data || data;
                        children = result.children || children;
                        normalizationType = result.normalizationType || normalizationType;
                    }
                    return renderer(tag, data, children, normalizationType);
                }
            }
            if (component._self._c) {
                component._self._c = patch(component._self._c);
            } else if (component.$createElement) {
                component.$createElement = patch(component.$createElement);
            } else {
                throw new Error("hook_renderer: no renderer to hook");
            }
        }

        static is_spacer(tag, data) {
            return tag == "div" && data && data.staticClass && typeof data.staticClass === "string"
                && data.staticClass.includes("spacer");
        }

        static add_config_button(component) {
            Component.hook_renderer(component, function (component, createElement, tag, _data, children) {
                if (tag != "v-footer") {
                    return;
                }
                const config_button = createElement("v-btn", {
                    staticClass: "flexcol mx-auto catalog_footer_btn",
                    attrs: {
                        text: "",
                        height: "54",
                    },
                    on: {
                        click: function () {
                            GM_config.open();
                        },
                    }
                }, [
                    createElement("v-icon", {
                        attrs: {
                            size: "32",
                            left: component.$vuetify.breakpoint.smAndUp
                        }
                    }, ["⚙"]),
                    createElement("span", {
                        staticClass: "footer_button_text",
                    }, [component._v(" VHAddon ")])
                ]);
                for (const i in children) {
                    const child = children[i];
                    if (Component.is_spacer(child.tag, child.data)) {
                        children.splice(i, 0, config_button);
                        return;
                    }
                }
                children.push(config_button);
            });
        }
    }

    class CatalogHub {
        #vue;

        constructor(vue) {
            this.#vue = vue;
            this.#remove_blocked_users_from_catalog();
            this.#make_video_preview();
            Component.add_config_button(vue);
        }

        static #is_preview_image(tag, data) {
            return tag == "v-img" && data && data.attrs && typeof data.attrs.src === "string"
                && data.attrs.src.startsWith("/storage/preview");
        }

        #make_video_preview() {
            if (GM_config.get("video_preview_type") !== "never") {
                Component.hook_renderer(this.#vue, function (_component, _createElement, tag, data, children) {
                    if (CatalogHub.#is_preview_image(tag, data)) {
                        return {
                            tag: "vhaddon_hlspreview",
                            data: {
                                props: {
                                    poster: data.attrs.src,
                                },
                            },
                            children: children,
                        };
                    }
                });
            }
        }

        #remove_blocked_users_from_catalog() {
            const vue = this.#vue;
            vue.$watch("streams", function () {
                this.$nextTick(function () {
                    const blocked_users = VHAddon.get_blocked_users();
                    if (blocked_users.length == 0) {
                        return;
                    }
                    for (const i in vue.streams) {
                        const stream = vue.streams[i];
                        if (blocked_users.includes(stream.username)) {
                            vue.streams.splice(i, 1);
                        }
                    }
                });
            }, { immediate: true, deep: true });
        }

    }

    class VideoQuality {
        #id;
        #data;

        constructor(id, data) {
            this.#id = id;
            this.#data = data;
        }

        get id() { return this.#id; }

        static format(data) {
            return data.width + "x" + data.height + " @ " + data.bitrate.toLocaleString();
        }

        toString() {
            if ("string" === typeof this.#data) {
                return this.#data;
            }
            return VideoQuality.format(this.#data);
        }
    }

    class StreamHub {
        #vue;

        constructor(vue) {
            this.#vue = vue;
            this.#disable_popups();
            this.#enable_video_quality_menu();
            Component.add_config_button(vue);
        }

        #disable_popups() {
            if (GM_config.get("block_popups")) {
                this.#vue.checker_welcome_condition = function () { };
                this.#vue.checker_task_condition = function () { };
            }
        }

        #enable_video_quality_menu() {
            if (!GM_config.get("quality_menu")) {
                return;
            }

            const vue = this.#vue;

            function create_hls_adapter(stream_id, level) {
                HLSPlayer.init().then(() => {
                    const video = document.getElementById(vue.hls_video_id);
                    vue.hls_adapter = new HLSPlayer(stream_id, video, level, {
                        "levels": function (levels) {
                            let items = [new VideoQuality(-1, "AUTO")];
                            for (const level_id in levels) {
                                const level = levels[level_id];
                                items.push(new VideoQuality(level_id, level));
                            }
                            vue.stream_resolutions = items;
                        },
                        "levelChanged": function (id) {
                            const prefix = this.autoLevelEnabled ? "AUTO" : "MANUAL";
                            vue.now_resolution = prefix + " - " + VideoQuality.format(this.levels[id]);
                        },
                    });
                });
            }

            vue.create_hls_adapter = function (stream_id) {
                vue.stream_hls_id = stream_id;
                create_hls_adapter(stream_id, GM_config.get("lowest_start_quality") ? 0 : undefined);
            };

            vue.select_resolution = function (selection) {
                if (!vue.hls_adapter) {
                    return;
                }
                const level = selection.id != -1 ? selection.id : undefined;
                vue.hls_adapter.destroy();
                create_hls_adapter(vue.stream_hls_id, level);
            };
        }
    }

    class VHAddon {
        #importer;
        #vue;

        constructor(importer) {
            this.#importer = importer;
            this.#vue = importer.module(importer.must_find_module_id(
                /\/\*\!\s*\*\sVue\.js v\d+\.\d+\.\d+/m,
                "chunk-vendors",
            ));

            const self = this;
            this.#vue.mixin({
                created: function () { self.#on_component_created(this); },
            });

            if (GM_config.get("video_preview_type") !== "never") {
                this.#vue.component("vhaddon_hlspreview", hlspreview);
            }
        }

        get importer() { return this.#importer };
        get vue() { return this.#vue; }

        async get_stream_key(user) {
            const response = await window.fetch(`https://viewhub.show/api/profile/${user}`);
            const profile = await response.json();
            if (profile.status == "ok" && profile.data.online && profile.data.stream_key) {
                return profile.data.stream_key;
            }
        }

        #on_component_created(component) {
            switch (Component.get_name(component)) {
                case "StreamHub":
                    new StreamHub(component);
                    break;
                case "CatalogHub":
                    new CatalogHub(component);
                    break;
            }
        }

        static get_blocked_users() {
            return GM_config.get("blocked_users").split(",");
        }

        static is_blocked_user(name) {
            return VHAddon.get_blocked_users().includes(name);
        }
    }

    (window.webpackJsonp = window.webpackJsonp || []).push([
        ["vhaddon"],
        {
            "vhaddon": (_, __, require) => {
                addon = new VHAddon(new Importer(require));
            },
        },
        [
            ["vhaddon", "chunk-vendors"]
        ]
    ]);
})();