Youtube/Holodex, ホロライブ配信スケジュール

ホロライブの直近のスケジュールをYoutubeで確認できます

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         Youtube/Holodex, Hololive streaming schedule
// @name:ja      Youtube/Holodex, ホロライブ配信スケジュール
// @namespace    http://tampermonkey.net/
// @version      1.0.10
// @description  You can check schedule of recently hololive stream on youtube
// @description:ja ホロライブの直近のスケジュールをYoutubeで確認できます
// @author       You
// @icon         https://www.google.com/s2/favicons?sz=64&domain=holodex.net
// @match        https://www.youtube.com/*
// @grant        GM.xmlHttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @run-at       document-end
// @noframes
// ==/UserScript==

(function() {
    'use strict';
    const VERSION = "1.0.10", APPNAME = "DHLSOY";
    const REFRESH_INTERVAL = 3 * 60 * 1000; // 5 mins
    const ERROR_TIMEOUT = 3000; // 3 secs
    const MAX_STREAM_LENGTH = 12 * 60 * 60 * 1000; // 12 hours

    const storage = {
        schedule: {
            get options() { return catchParseError(() => JSON.parse(localStorage.ht_s_options), null); },
            set options(value) { localStorage.ht_s_options = JSON.stringify(value); },
        },
        archive: {
            get options() { return catchParseError(() => JSON.parse(localStorage.ht_a_options), null); },
            set options(value) { localStorage.ht_a_options = JSON.stringify(value); },
        }
    }

    var holodex = new Holodex(),
        quickBar = new QuickBar(),
        customView = new CustomViewer(),
        customFilters;

    // console.log(filterTree, filters);

    initilize();

    function initilize() {
        if (document.enabledHololiveSchedule) return;
        document.enabledHololiveSchedule = true;

        try {
            customFilters = initilizeFilters();
            quickBar.render();
            customView.render();
        } catch(ex) {
            console.log(ex);
        }
    }

    function catchParseError(func, defaultValue) {
        try {
            return func();
        } catch {
            return defaultValue;
        }
    }

    function removeAllChildNodes(parent) {
        while (parent.firstChild) {
            parent.removeChild(parent.firstChild);
        }
    }

    function QuickBar() {
        const self = this;
        const localize = {
            ja: {
                archive: "終了した放送を見る",
                powered: "参照元: Holodex",
            },
            en: {
                archive: "View archives",
                powered: "Source: Holotools",
            }
        }
        const t = localize[window.navigator.language] || localize.en;

        this.$preview = null;
        this.$previewThumbnail = null;
        this.$previewTitle = null;

        // ui
        this.render = async function () {
            this.filter = customFilters.all;
            this.$style = document.createElement("style");
            this.$style.id = "hololive-schedule-style";
            this.$style.innerHTML = `
#hololive-schedule {
    --color: #212121;
    --background-color: #ffffff;
    --hover-background-color: #ececec;
    --icon-hover-background-color: #d9d9d9;
    --icon-fill: #212121;
}

[dark] #hololive-schedule {
    --color: #ffffff;
    --background-color: #212121;
    --hover-background-color: #404040;
    --icon-hover-background-color: #4c4c4c;
    --icon-fill: #ffffff;
}

#hololive-schedule {
    position: fixed;
    bottom: 0;
    left: 0;
    min-width: 100%;
    min-height: 97px;
    opacity: 0;
    scrollbar-width: none;
    box-sizing: border-box;
    background-color: var(--background-color);
    z-index: 2031; /* drwaer is 2030 */
    transition: opacity .1s linear, height .3s linear;
    pointer-events: none;
}
#hololive-schedule:hover {
    opacity: 1;
    scrollbar-width: none;
}
#hololive-schedule.visible {
    pointer-events: unset;
}
#hololive-schedule #schedule {
    position: relative;
    bottom: 0;
    left: 0;
    min-width: 100%;
    padding: 8px;
    white-space: nowrap;
    scrollbar-width: none;
    box-sizing: border-box;
    overflow-y: hidden;
    overflow-x: scroll;
    background-color: var(--background-color);
    transition: left .2s ease, max-height .5s linear;
}
#hololive-schedule:hover #schedule  {
    scrollbar-width: none;
}
#hololive-schedule #schedule::-webkit-scrollbar {
    display: none;
}
#hololive-schedule .hololive-stream {
    display: inline-block;
    position: relative;
    border: solid 3px;
    border-radius: 3px;
    font-size: 10px;
    margin: 0 0 0 8px;
}
#hololive-schedule .hololive-stream.hidden {
    display: none;
}
#hololive-schedule .hololive-stream .thumbnail {
    vertical-align: middle;
    width: 128px;
    height: 72px;
    border-radius: 2px;
    background-size: contain;
    background-repeat: 1;
    background-position: center center;
    opacity: 0;
    transition: opacity 0.1s ease;
}
#hololive-schedule .hololive-stream .thumbnail.loaded {
    opacity: 1;
}
#hololive-schedule .hololive-stream .title-wrapper {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    overflow: hidden;
    color: #202020;
    background: #fffa;
    z-index: 1;
}
#hololive-schedule .hololive-stream:hover .title {
    /*animation: textAnimation 4s linear infinite;*/
}
#hololive-schedule .hololive-stream .date {
    position: absolute;
    padding: 1px 3px;
    color: #202020;
    background: #fffd;
    font-weight: bold;
    z-index: 1;
}
#hololive-schedule .hololive-stream .photo {
    position: absolute;
    right: -8px;
    top: -8px;
    width: 32px;
    height: 32px;
    border: solid 2px #fff;
    border-radius: 18px;
    z-index: 1;
    background-color: var(--background-color);
    background-size: cover;
    opacity: 0;
    transition: opacity 0.1s ease;
}
#hololive-schedule .hololive-stream .photo.loaded {
    opacity: 1;
}
#hololive-schedule .hololive-stream[stream-status="live"],
#hololive-schedule .hololive-stream[stream-status="live"] .photo {
    border-color: crimson;
}
#hololive-schedule .hololive-stream[stream-status="upcoming"],
#hololive-schedule .hololive-stream[stream-status="upcoming"] .photo {
    border-color: deepskyblue;
}
#hololive-schedule .hololive-stream[stream-status="ended"],
#hololive-schedule .hololive-stream[stream-status="ended"] .photo {
    border-color: gray;
}

#hololive-schedule .tools {
    display: block;
    position: absolute;
    height: fit-content;
    top: 0;
    transform: translateY(-100%);
    background-color: var(--background-color);
    color: var(--icon-fill);
    vertical-align: middle;

    transition: opacity .2s linear, width .1s linear;
}
#hololive-schedule .tools.left {
    left: 0;
    padding-left: 8px;
    border-radius: 0 5px 0 0;
}
#hololive-schedule .tools a {
    display: inline-block;
    padding: 6px 8px;
    cursor: pointer;
    vertical-align: middle;
    color: var(--icon-fill);
    text-decoration: none;
    text-align: center;
    transition: background .1s linear, padding .1s linear;
}
#hololive-schedule .tools a:last-child {
    border-radius: 0 5px 0 0;
}
#hololive-schedule .tools a:hover {
    background: var(--hover-background-color);
}
#hololive-schedule .tools a svg {
    display: inlnie-block;
    height: 14px;
}
#hololive-schedule .tools a svg .shape {
    fill: var(--icon-fill);
    transition: fill .1s linear;
}
#hololive-schedule #contents .contents.hidden {
    display: none;
    width: 0;
}
#hololive-schedule #preview {
    position: fixed;
    bottom: 100px;
    left: 0;
    right: 0;
    text-align: center;
    z-index: 2031;
    pointer-events: none;
    transition: opacity 0.1s ease;
}
#hololive-schedule #preview.hidden {
    opacity: 0;
}
#hololive-schedule #thumbnail-preview {
    display: block;
    margin: 0 auto;
    width: 512px;
    height: 288px;
    background-size: 100%;
    background-position: center;
    border-radius: 4px;
    z-index: 2031;
    pointer-events: none;
    box-shadow: #000 0px 0px 12px;
    border: solid 5px;
}
#hololive-schedule #thumbnail-preview iframe {
    width: 512px;
    height: 288px;
}
#hololive-schedule #title-preview {
    display: inline-block;
    font-size: 20px;
    padding: 6px 12px;
    border-radius: 4px;
    color: #303030;
    background: #efefef;
    white-space: nowrap;
    margin-top: 8px;
}
#hololive-schedule #thumbnail-preview[stream-status="live"] {
    border-color: crimson;
}
#hololive-schedule #thumbnail-preview[stream-status="upcoming"] {
    border-color: deepskyblue;
}
#hololive-schedule #thumbnail-preview[stream-status="ended"] {
    border-color: gray;
}
`;
            document.body.appendChild(this.$style);

            this.$container = document.createElement("div");
            this.$container.id = "hololive-schedule";
            this.$container.classList.add("visible");
            this.$container.innerHTML = `
<div class="tools left">
    <a class="filter" filter="all"><span>All</span></a>
    <a class="filter" filter="jp"><span>JP</span></a>
    <a class="filter" filter="en"><span>EN</span></a>
    <a class="filter" filter="id"><span>ID</span></a>
    <a class="filter" filter="devis"><span>DEV_IS</span></a>
    <a class="filter" filter="stars_all">STARS</a>
    <a id="archive" title="${t.archive}">
        <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="28px" height="28px" viewBox="0 0 512 512">
            <path class="shape" d="M464,56v40h-40V56H88v40H48V56H0v400h48v-40h40v40h336v-40h40v40h48V56H464z M88,354.672H48v-64h40V354.672z
		 M88,221.328H48v-64h40V221.328z M308.094,266.047l-101.734,60.734c-0.75,0.438-1.656,0.469-2.406,0.031
		c-0.734-0.422-1.203-1.219-1.203-2.094V264v-60.719c0-0.859,0.469-1.656,1.203-2.094c0.75-0.406,1.656-0.391,2.406,0.031
		l101.734,60.75c0.719,0.406,1.156,1.203,1.156,2.031C309.25,264.844,308.813,265.625,308.094,266.047z M464,354.672h-40v-64h40
		V354.672z M464,221.328h-40v-64h40V221.328z"></path>
        </svg>
    </a>
    <a id="powered" href="https://holodex.net/" target="blank_">${t.powered}</a>
</div>
<div id="schedule" style="left: 0px;">
</div>
<div id="preview" class="hidden">
    <div id="thumbnail-preview"></div>
    <div id="title-preview"></div>
</div>
`;
            document.body.appendChild(this.$container);

            this.$tools = this.$container.querySelector(".tools");
            this.$schedule = this.$container.querySelector("#schedule");
            this.$preview = this.$container.querySelector("#preview");
            this.$previewTitle = this.$preview.querySelector("#title-preview");
            this.$previewThumbnail = this.$preview.querySelector("#thumbnail-preview");

            // events

            window.addEventListener("wheel", this.updateVisibility);
            this.$container.addEventListener("wheel", this.onScrollContainer, true);

            this.$container.addEventListener("mouseenter", async (ev) => {
                // console.log("mouseenter", ev.target.classList.toString());

                this.updateVisibility();
                if (await this.updateSchedule()) {
                    this.drawSchedule();
                }
            }, false);

            this.$tools.addEventListener("mouseenter", self.$preview.classList.add("hidden"), false);
            this.$container.addEventListener("mouseleave", this.close, false);

            this.updateVisibility();

            this.$container.querySelectorAll(".filter").forEach($filter => {

                var filter = customFilters[$filter.getAttribute("filter")];
                if (filter) {
                    $filter.filter = filter;
                    $filter.addEventListener("click", ev => this.filterByCategory(filter), false);
                }
            });

            this.$container.querySelector("#archive").addEventListener("click", this.onOpenCustomView);
        }

        this.drawSchedule = function () {
            // console.log(this.schedule);
            var streams = this.schedule;

            removeAllChildNodes(this.$schedule);
            this.$schedule.style.left = "0px";

            // console.log(streams);
            streams
                .filter(data => !(data.status == "upcoming" && new Date().getTime() - new Date(data.available_at).getTime() > 0)) // fix to status bug in holodex
                .filter(data => !(data.status == "live" && new Date().getTime() - new Date(data.available_at).getTime() > MAX_STREAM_LENGTH)) // older
                .forEach(streamData => {
                var stream = this.createStream(streamData);
                this.$schedule.appendChild(stream);
            });

            this.$schedule.querySelectorAll(".thumbnail").forEach((element, index) => {
                setTimeout(() => element.classList.add("loaded"), index * 20);
            });
            this.$schedule.querySelectorAll(".photo").forEach((element, index) => {
                setTimeout(() => element.classList.add("loaded"), index * 20);
            });

            this.filterByCategory();
        }

        // 枠をつくる
        this.createStream = function (data) {
            // console.log(details);
            var $stream = document.createElement("a");
            $stream.data = data;
            $stream.classList.add("hololive-stream");
            $stream.setAttribute("stream-status", data.status);

            var start = new Date(data.start_actual || data.start_scheduled);
            var href, site = "";
            if (data.type == "placeholder") {
                // twitch
                if (data.link && data.link.indexOf("https://twitch.tv/") == 0) {
                    data.thumbnail = data.thumbnail.replace("1920x1080", "426x240");
                    site = "twitch";
                }
                // twitter
                if (data.thumbnail && data.thumbnail.indexOf("https://pbs.twimg.com/") == 0) {
                    data.thumbnail = data.thumbnail.replace("name=large", "name=small");
                    site = "twitter";
                }
                if (data.link && data.link.indexOf("agqr") >= 0) {
                    site = "radio";
                }

                href = data.link;
                $stream.target = "_blank";
            } else if (data.type == "stream") {
                data.thumbnail = `https://i.ytimg.com/vi/${data.id}/mqdefault.jpg?${start.getTime()}`;
                href = "/watch/" + data.id;
            }

            var time = this.toLiveDate(data.start_actual, data.start_scheduled, data.ended, data.published_at);
            var icon = data.channel.photo.replace("=s800", "=s144");

            $stream.href = href;
            $stream.innerHTML = `
<div class="date">${time}</div>
<div class="thumbnail" style="background-image: url('${data.thumbnail}')"></div>
<div class="title-wrapper"><div class="title">${data.title}</div></div>
<div class="photo" style="background-image: url('${icon}');" />
`;
            $stream.addEventListener("mouseenter", this.previewStream, false);

            return $stream;
        }

        this.initilizeTwitchAPI = function () {
            var script = document.createElement("script");
            script.src = "https://player.twitch.tv/js/embed/v1.js";
            return new Promise((resolve) => {
                script.addEventListener("load", () => setTimeout(resolve, 50));
                document.querySelector("head").appendChild(script);
            });
        }

        this.initilizeYoutubeAPI = function () {
            var script = document.createElement("script");
            script.src = "https://www.youtube.com/player_api";
            return new Promise((resolve) => {
                script.addEventListener("load", () => setTimeout(resolve, 50));
                document.querySelector("head").appendChild(script);
            });
        }

        this.previewTwitch = function (channel) {
            var player = new Twitch.Player("thumbnail-preview", {
                channel: channel,
                parent: ["www.youtube.com"]
            });
            player.addEventListener(Twitch.Player.READY, () => {
                setTimeout(() => {
                    player.setMuted(false);
                }, 200);
            });
            return player;
        }

        this.previewYoutube = function (videoId) {
            var player = new YT.Player("thumbnail-preview-placeholder", {
                videoId: videoId,
                playerVars: {
                    autoplay: 1,
                    enablejsapi: 1,
                    origin: "www.youtube.com",
                    controls: 1, // 0: ユーザー設定の音量が維持されない
                    fs: 0,
                    modestbranding: 0,
                },
                events: {
                    'onReady': (ev) => {
                        setTimeout(() => {
                            if (!ev.target) return;
                            ev.target.playVideo();
                        }, 200);
                    },
                    'onError': (ev) => {
                        console.log(ev);
                    },
                }
            });
            return player;
        }

        this.previewStream = async function (ev) {
            var isLivePreview = false;
            var data = ev.target.data;
            if (data) {
                self.$previewTitle.innerText = data.title;
                self.$previewThumbnail.style.backgroundImage = `url('${data.thumbnail}')`;
                self.$previewThumbnail.setAttribute("stream-status", data.status);

                var mainVideo = document.querySelector("video");
                removeAllChildNodes(self.$previewThumbnail);
                if (data.topic_id == "membersonly") {
                    isLivePreview = false;
                }
                // youtube
                else if (data.type == "stream" && data.status == "live") {
                    if (typeof YT == "undefined") {
                        await self.initilizeYoutubeAPI();
                    }
                    self.$previewThumbnail.innerHTML = `<div id="thumbnail-preview-placeholder"></div>`;
                    var ytplayer = self.previewYoutube(data.id);
                    isLivePreview = true;
                }
                // twitch
                else if (data.status == "live" && data.link && data.link.indexOf("https://twitch.tv/") == 0) {
                    var channel = data.link.split("/").slice(-1)[0];
                    if (typeof Twitch == "undefined") {
                        await self.initilizeTwitchAPI();
                    }
                    self.previewTwitch(channel);
                    isLivePreview = true;
                }

                if (mainVideo) {
                    mainVideo.muted = isLivePreview;
                }
            }

            self.$preview.classList.toggle("hidden", !data);
        }

        // フルスクリーンのときは表示しない
        this.updateVisibility = function () {
            var html = document.querySelector("html");
            var app = document.querySelector("ytd-app");
            var isScrolled = ((app && app.scrollTop) || html.scrollTop || html.scrollY || window.scrollY || window.scrollTop || document.body.scrollY || document.body.scrollTop) > 100;

            self.$container.classList.toggle("visible", isScrolled);
        }

        this.filterByCategory = function (filter) {
            this.filter = filter || this.filter;
            if (this.filter) {
                this.$container.querySelectorAll(".hololive-stream").forEach(e => e.classList.toggle("hidden", !filter.match(e.data)));
            }
        }

        // data
        this.isRefresing = false;
        this.schedule = [];
        this.lastUpdated = 0;

        this.updateSchedule = async function (force) {
            // console.log(`refreshList(${force})`);

            try {
                if (this.isRefreshing) return false;
                this.isRefreshing = true;

                // console.log(`cacheUpdated:${cacheUpdated}, lastUpdated:${this.lastUpdated}, ${new Date().getTime()}`);

                // 十分に新しい、HoloDexから更新の必要はない
                if (!force && Date.now() - this.lastUpdated < REFRESH_INTERVAL) {
                    return false;
                }

                var response = await holodex.getLive().catch(ex => {
                    console.log("cache", ex);
                });

                if (!response) return false;

                if (Array.isArray(response)) {
                    this.lastUpdated = Date.now();
                    this.schedule = response;

                    return response;
                }

                return false;
            } catch (ex) {
                console.log(ex);
            } finally {
                this.setTimeoutToRefresh();
            }
        }

        this.setTimeoutToRefresh = function () {
            setTimeout(() => {
                this.isRefreshing = false;
            }, ERROR_TIMEOUT);
        }


        // utils
        this.toLiveDate = function(schedule, start, end, publish) {
            // console.log(schedule, start, end, publish);

            if (end) {
                const diff = new Date(end).getTime() - new Date(start).getTime();
                const dm = Math.floor(diff / 1000 / 60 % 60);
                const dh = Math.floor(diff / 1000 / 60 / 60);

                var span = "";
                if (dh > 0) {
                    span += dh + "h ";
                }
                span += dm + "m";

                return span;
            }

            start = new Date(start || schedule || publish);
            const h = ("0" + start.getHours()).slice(-2);
            const m = ("0" + start.getMinutes()).slice(-2);
            if (start.getDate() != new Date().getDate()) {
                return `${start.getDate()}-${h}:${m}`;
            } else {
                return `${h}:${m}`;
            }
        }

        // handler

        this.onScrollContainer = function (ev) {
            // console.log(ev);

            ev.preventDefault();
            ev.stopPropagation();
            ev.stopImmediatePropagation();

            // console.log("on wheel", ev, document.querySelector("#hololive-schedule").clientWidth, document.documentElement.clientWidth, container.style.left);

            const lp = parseFloat(self.$schedule.style.left) || 0,
                  dw = document.documentElement.clientWidth,
                  cw = self.$schedule.clientWidth;

            // console.log (contents, lp, dw, cw);

            if (ev.deltaY > 0) {
                self.$schedule.style.left = Math.max(lp - dw / 5, - cw + dw) + "px";
            } else if (ev.deltaY < 0) {
                self.$schedule.style.left = Math.min(lp + dw / 5, 0) + "px";
            } else {
                self.$schedule.style.left = - cw + dw;
            }

            return false;
        }

        this.onOpenCustomView = function (ev) {
            customView.open();
        }

        this.close = function() {
            self.$preview.classList.add("hidden");
            removeAllChildNodes(self.$previewThumbnail); // stop iframe preview
            document.querySelector("video").muted = false;
        }
    }

    function CustomViewer() {
        const localize = {
            ja: {
                next: "NEXT",
                filter: {
                    favorite: "お気に入り",
                    lives: "配信中を含める",
                    all: "ALL",
                    jp: "ホロライブ",
                    en: "ホロライブ EN",
                    id: "ホロライブ ID",
                    devis: "DEV_IS",
                    stars: "ホロスターズ",
                    stars_en: "ホロスターズ EN",
                    outside: "外部コラボ",
                    video: "動画",
                    singing: "歌枠",
                    talk: "雑談",
                    recommend: "注目",
                    pvp: "対戦ゲーム",
                    rpg: "RPG",
                    coop: "ローカル対戦/協力",
                    horror: "ホラー",
                    hololive: "Hololive Indie",
                    membersonly: "メンバー限定",
                },
                topic: {
                    membersonly: "メン限",
                    totsu: "凸待ち",
                    talk: "雑談",
                    original_song: "オリジナルソング",
                    music_cover: "歌ってみた",
                    dancing: "踊ってみた",
                    drawing: "お絵かき",
                    morning: "朝活",
                    singing: "歌枠",
                    celebration: "記念枠",
                    marshmallow: "マシュマロ",
                    shorts: "ショート動画",
                    superchat_reading: "スパチャ読み",
                    anniversary: "周年記念",
                    endurance: "耐久",
                    announce: "お知らせ",
                    watchalong: "同時視聴",
                    clubhouse51: "アソビ大全51",
                    outfit_reveal: "新衣装",
                    debut_stream: "初配信",
                    animation: "アニメ",
                    camera_stream: "カメラ配信",
                }
            },
            en: {
                next: "NEXT",
                filter: {
                    favorite: "Favorite",
                    lives: "Include lives",
                    all: "ALL",
                    jp: "Hololive JP",
                    en: "Hololive EN",
                    id: "Hololive ID",
                    devis: "DEV_IS",
                    stars: "Holostars",
                    stars_en: "Holostars EN",
                    outside: "Collabo with outside",
                    video: "Video",
                    singing: "Singing",
                    talk: "Talk",
                    recommend: "Recommend",
                    pvp: "PvP",
                    rpg: "RPG",
                    coop: "Co-op",
                    hololive: "Hololive Indie",
                    membersonly: "Members Only"
                },
                topic: {}
            },
        };

        const t = localize[window.navigator.language] || localize.en;
        var self = this;

        // ui

        this.render = function () {
            this.config = Object.assign({
                lives: false,
                favorite: false,
            }, storage.archive.options);

            this.$style = document.createElement("style");
            this.$style.id = "hololive-custom-style";
            this.$style.innerHTML = `
#hololive-custom-viewer {
    --custom-viewer-background: #fff;

    --custom-viewer-items-background: #fff;
    --custom-viewer-items-caption-color: #212121;
    --custom-viewer-items-caption-hover-text-shadow: #464ea7a8;

    --custom-viewer-items-info-color: #202020;
    --custom-viewer-items-info-background: #fffd;

    --custom-viewer-filter-color: #202020;
    --custom-viewer-filter-hover-background: #bfbfbf;
    --custom-viewer-filter-enabled-fill: gold;

    --custom-viewer-slim-filter-background: #cfcfcf;
    --custom-viewer-slim-filter-hover-background: #bfbfbf;

    --custom-viewer-filter-recommend-color: #000;
    --custom-viewer-filter-recommend-background: gold;

    --custom-viewer-next-color: #202020;
    --custom-viewer-next-background: #cfcfcf;
    --custom-viewer-next-hover-background: #bfbfbf;
}
[dark] #hololive-custom-viewer {
    --custom-viewer-background: #000;

    --custom-viewer-items-background: #000;
    --custom-viewer-items-caption-color: #ececec;
    --custom-viewer-items-caption-hover-text-shadow: #fff;

    --custom-viewer-items-info-color: #fff;
    --custom-viewer-items-info-background: #202020dd;

    --custom-viewer-filter-color: #ececec;
    --custom-viewer-filter-hover-background: #404040;
    --custom-viewer-filter-enabled-fill: gold;

    --custom-viewer-slim-filter-background: #202020;
    --custom-viewer-slim-filter-hover-background: #404040;

    --custom-viewer-filter-recommend-color: #000;
    --custom-viewer-filter-recommend-background: gold;

    --custom-viewer-next-color: #ececec;
    --custom-viewer-next-background: #202020;
    --custom-viewer-next-hover-background: #404040;
}
#hololive-custom-viewer {
    display: flex;
    position: fixed;
    justify-content: center;
    bottom: 0;
    right: 0;
    left: 0;
    top: 0;
    background: var(--custom-viewer-items-background);
    z-index: 3000;
    padding: 30px;
    transitioin: opacity 0.1s linear;
}
#hololive-custom-viewer.hidden {
    opacity: 0;
    display: none;
}
#hololive-custom-viewer #sub {
    width: 200px;
    overflow-y: scroll;
    scrollbar-width: none;
    box-sizing: border-box;
}
#hololive-custom-viewer #main {
    overflow-y: scroll;
    scrollbar-width: none;
    margin: 10px 0;
    padding: 20px;
    width: calc(min(100%,1280px));
    text-align: left;
    box-sizing: border-box;
}
#hololive-custom-viewer #sub::-webkit-scrollbar,
#hololive-custom-viewer #sub::-webkit-scrollbar-thumb,
#hololive-custom-viewer #main::-webkit-scrollbar,
#hololive-custom-viewer #main::-webkit-scrollbar-thumb {
    display: none;
}
#hololive-custom-viewer .hololive-stream {
    display: inline-block;
    position: relative;
    font-size: 10px;
    border-radius: 5px;
    margin: 0 0 16px 8px;
    width: 192px;
    vertical-align: top;
    text-decoration: none;
    color: #acacac;
    overflow: hidden;
}
#hololive-custom-viewer .hololive-stream.hidden {
    display: none;
}
#hololive-custom-viewer .hololive-stream .thumbnail {
    position: relative;
    vertical-align: middle;
    width: 192px;
    height: 108px;
    box-sizing: border-box;
    border-radius: 3px;
    background-size: 100%;
    background-repeat: 1;
    background-position: center center;
    transition: background-size 0.15s ease-in-out;
}
#hololive-custom-viewer .hololive-stream:hover .thumbnail {
    background-size: 108%;
}
#hololive-custom-viewer .hololive-stream .title-wrapper {
    color: var(--custom-viewer-items-caption-color);
    margin: 0.5rem;
    font-size: 1.5rem;
    line-height: 1.75rem;
    height: 5.25rem;
    word-break: break-all;
    overflow: hidden;
    transition: text-shadow 0.15s ease-in-out;
}
#hololive-custom-viewer .hololive-stream:hover .title-wrapper {
    text-shadow: 0px 0px 2px var(--custom-viewer-items-caption-hover-text-shadow);
}

#hololive-custom-viewer .hololive-stream .date,
#hololive-custom-viewer .hololive-stream .topic {
    position: absolute;
    padding: 1px 5px;
    left: 3px;
    border-radius: 2px;
    color: var(--custom-viewer-items-info-color);
    background: var(--custom-viewer-items-info-background);
    z-index: 1;
    letter-spacing: .025em;
    white-space: nowrap;
    overflow: hidden;
    transition: opacity 0.1s linear;
}

#hololive-custom-viewer .hololive-stream:hover .date,
#hololive-custom-viewer .hololive-stream:hover .topic {
    opacity: 0;
}
#hololive-custom-viewer .hololive-stream .date {
    top: 2px;
    font-size: 10px;
}
#hololive-custom-viewer .hololive-stream[stream-status=live] .date {
    background: #f00c;
}
#hololive-custom-viewer .hololive-stream .topic {
    font-size: 12px;
    bottom: 2px;
    text-transform: capitalize;
}
#hololive-custom-viewer .hololive-stream .title {
    display: inline;
}
#hololive-custom-viewer .hololive-stream .name {
    display: inline;
    vertical-align: middle;
    margin-left: 6px;
    font-size: 9px;
    opacity: 0.7;
}
#hololive-custom-viewer .hololive-stream .photo {
    position: absolute;
    right: -12px;
    top: -12px;
    width: 48px;
    height: 48px;
    border-radius: 24px;
    background-size: cover;
    transition: transform 0.15s ease-in-out, opacity 0.15s ease-in-out;
}
#hololive-custom-viewer .hololive-stream:hover .photo {
    transform: scale(0) translate(10px, -10px);
    opacity: 0;
}
#hololive-custom-viewer .hololive-stream .site {
    position: absolute;
    right: 3px;
    bottom: 3px;
    width: 24px;
    height: 24px;
    background-size: 100%;
    transition: opacity 0.1s ease-in-out;
}
#hololive-custom-viewer .hololive-stream .site.twitch {
    background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAETSURBVHgB7ZU7DoJAEIb/JV7MBq/hCVROIJ7AqI2t0d5WsTF2dhzBI1hbsLIYwyPADgzrFvI1PJbk+5lZBoEaNq6cR4APA0wDIdTRgQV5lgGI8skZnbAe5a8ditwkjk15LoANeS5AW7nqabGvdfcrA9iiD9AHsB5gACZVI5o6uv+rBbct7AW4H4Dw+DmPp+7ipwGU/L5P5V4g/O8aexM2kkusvEsqVxitQOHNd7F8VnyGXIHiny37mWVFZSTyQIzL1tgV0MljQrwwq1pkBaDIoxeG3lU80XUAgvyhk/MC6OSOXs4KoJWfxIPysACRlSslV1YGpwIhV65oOwlDygYzEqBuqLShUQuSWd6hvBFLV/owwBuAI3t8NBey8QAAAABJRU5ErkJggg==');
}
#hololive-custom-viewer .hololive-stream .site.twitter {
    background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJkSURBVHgB7VZBbtpQEH3zIW0WVYuXVaH4Bs0NSk4AOUFhEarskhMknIDsqkKlcIT0BNAT1D1B3ZJK3dmVuirwp/MhVmzAxiagKBJv9+ePZ97M/JkxsMMODwzChlD84FWQp3MxeCDHAhiumB+MJrr1+8Ryw3p/9+H4DctfIPCq49Xlw8Kv99YlMuB19885gy/i7llziwGfFFWJyR02XzSCuwiBUse7BlFVaz5LS8KQVkRXaXRJsqImfDjKSZBNyzEyFWFKVJ4KFbWLElUao6KbSk8i9TXgTPaorxTskPwOxa7/9baGt4zg8oQbNyfWYJlRU0/KUx9ZwNwYNq1ecFRzl18QpW0bB0Ks//KjV1uwlbuLJA3GxEdh5wb5yGEPl3qMd2xecYQHKnlFlVLX95kxYCFKGg5IlU2a0uLpCM68LEJA+sJ/Dm6Jy3aMjQIRakRUm+UuvfOp/X34iQSejeFo0Hdx4optG5uFH/R+GHNvANcm3VtwLs+Lvy2TRwhIOnrYHhysIuDKcCDwGbYAjglOzQt+HssElF6dvoNNOZeuCSbfSgIGMjILMo4/ExZf7TqghNLmlwm1gpSC2tmaLAZMvWGz0Iu7XpqBm2NrQNN5cD+Y5ZOTdZyok3RZMusZOJUN+QZrQFb0oQkG6xIIYHe8A03Unx/Ryd6jS2ctAsbxmFRVynGKlM5na5ePVkUe0p+h9MmraS2zXqYgmSWjOPtElHbLTVB3Q79gqQlMScxqXpeav0UWiGMmXKSNOpZAAPvKs/U/1MRoxRxl+5WD+psUy2D5IdmRVoWjnqDnLlkyO+zwaPAf1zXwZL751PUAAAAASUVORK5CYII=');
}
#hololive-custom-viewer .hololive-stream:hover .site {
    opacity: 0;
}
#hololive-custom-viewer .button {
    display: block;
    font-size: 14px;
    width: 100%;
    padding: 8px 16px;
    cursor: pointer;
    maring: 0;
    color: var(--custom-viewer-filter-color);
    background: var(--custom-viewer-background);
    border-radius: 3px;
    box-sizing: border-box;
    transition: background 0.1s linear, padding 0.1s ease-in-out;
}
#hololive-custom-viewer .button svg {
    fill: var(--custom-viewer-filter-color);
    vertical-align: middle;
}
#hololive-custom-viewer .button.enabled svg {
    fill: var(--custom-viewer-filter-enabled-fill);
}
#hololive-custom-viewer .button span {
    padding-left: 8px;
}
#hololive-custom-viewer .button:hover {
    background: var(--custom-viewer-filter-hover-background);
    padding-left: 24px;
}
#hololive-custom-viewer .button.filter[filter=recommend],
#hololive-custom-viewer .button.reload {
    color: var(--custom-viewer-filter-recommend-color);
    background: var(--custom-viewer-filter-recommend-background);
}
#hololive-custom-viewer .button.reload svg {
    fill: var(--custom-viewer-filter-recommend-color);
}
#hololive-custom-viewer .button.filter[filter=recommend]:hover,
#hololive-custom-viewer .button.reload:hover {
    background: var(--custom-viewer-filter-recommend-background);
}
#hololive-custom-viewer .button.filter[count]::after {
    display: inline-block;
    content: attr(count);
    font-weight: bold;
    margin-left: 12px;
}
#hololive-custom-viewer .button.hidden {
    display: none;
}
#hololive-custom-viewer .child {
    padding-left: 8px;
}
#hololive-custom-viewer #next {
    display: block;
    font-size: 16px;
    color: #efefef;
    text-align: center;
    margin-top: 20px;
    padding: 16px;
    cursor: pointer;
    color: var(--custom-viewer-next-color);
    background: var(--custom-viewer-next-background);
    padding: 16px;
    border-radius: 3px;
    transition: background 0.1s linear;
}
#hololive-custom-viewer #next.hidden {
    opacity: 0;
}
#hololive-custom-viewer #next:hover {
    background: var(--custom-viewer-next-hover-background);
}
/* slim style */
@media screen and (max-width: 1600px) {
    #hololive-custom-viewer {
        flex-direction: column;
        justify-content: start;
    }
    #hololive-custom-viewer #sub {
        width: calc(min(100%,1280px));
        overflow-y: unset;
        margin: 0 auto;
    }
    #hololive-custom-viewer #main {
        max-width: calc(min(100%,1280px));
        text-align: center;
        margin: 10px auto;
    }
    #hololive-custom-viewer .child {
        padding-left: 0;
        display: inline;
    }
    #hololive-custom-viewer .button {
        display: inline-block;
        padding: 6px 16px;
        width: unset;
        margin: 0 6px 6px 0;
        line-height: 1.5;
        border-radius: 20px;
        background: var(--custom-viewer-slim-filter-background);
    }
    #hololive-custom-viewer .button:hover {
        background: var(--custom-viewer-slim-filter-hover-background);
        padding-left: 16px;
    }
}

            `;
            document.body.appendChild(this.$style);

            this.$container = document.createElement("div");
            this.$container.id = "hololive-custom-viewer";
            this.$container.classList.add("hidden");
            this.$container.innerHTML = `
<div id="sub">
<!--
<a class="button filter no-count ${this.config.favorite ? "enabled" : ""}" filter="favorite">
    <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="14px" height="14px" viewBox="0 0 512 512">
        <path class="shape" d="M473.984,74.248c-50.688-50.703-132.875-50.703-183.563,0c-17.563,17.547-29.031,38.891-34.438,61.391
        c-5.375-22.5-16.844-43.844-34.406-61.391c-50.688-50.703-132.875-50.703-183.563,0c-50.688,50.688-50.688,132.875,0,183.547
		l217.969,217.984l218-217.984C524.672,207.123,524.672,124.936,473.984,74.248z" ></path>
    </svg>
    <span>${t.filter.favorite}</span>
</a>
-->
<a class="button filter no-count ${this.config.lives ? "enabled" : ""}" filter="lives">
    <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="14px" height="14px" viewBox="0 0 512 512">
        <g><polygon points="352.188,0 131.781,290.125 224.172,290.125 148.313,512 380.219,223.438 284.328,223.438"></polygon></g>
    </svg>
    <span>${t.filter.lives}</span>
</a>
<a class="button filter" filter="all">${t.filter.all}</a>
<a class="button filter" filter="recommend">${t.filter.recommend}</a>
<a class="button filter" filter="jp">${t.filter.jp}</a>
<a class="button filter" filter="en">${t.filter.en}</a>
<a class="button filter" filter="id">${t.filter.id}</a>
<a class="button filter" filter="devis">${t.filter.devis}</a>
<a class="button filter" filter="stars">${t.filter.stars}</a>
<a class="button filter" filter="stars_en">${t.filter.stars_en}</a>
<a class="button filter" filter="outside">${t.filter.outside}</a>
<a class="button filter" filter="talk">${t.filter.talk}</a>
<a class="button filter" filter="singing">${t.filter.singing}</a>
<a class="button filter" filter="rpg">${t.filter.rpg}</a>
<a class="button filter" filter="horror">${t.filter.horror}</a>
<a class="button filter" filter="pvp">${t.filter.pvp}</a>
<a class="button filter" filter="coop">${t.filter.coop}</a>
<a class="button filter" filter="hololive">${t.filter.hololive}</a>
<a class="button filter" filter="video">${t.filter.video}</a>
<a class="button filter" filter="membersonly">${t.filter.membersonly}</a>
<a class="button reload hidden">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" height="14px" width="14px" viewBox="0 0 512 512">
<g>
	<path d="M446.025,92.206c-40.762-42.394-97.487-69.642-160.383-72.182c-15.791-0.638-29.114,11.648-29.752,27.433
		c-0.638,15.791,11.648,29.114,27.426,29.76c47.715,1.943,90.45,22.481,121.479,54.681c30.987,32.235,49.956,75.765,49.971,124.011
		c-0.015,49.481-19.977,94.011-52.383,126.474c-32.462,32.413-76.999,52.368-126.472,52.382
		c-49.474-0.015-94.025-19.97-126.474-52.382c-32.405-32.463-52.368-76.992-52.382-126.474c0-3.483,0.106-6.938,0.302-10.364
		l34.091,16.827c3.702,1.824,8.002,1.852,11.35,0.086c3.362-1.788,5.349-5.137,5.264-8.896l-3.362-149.834
		c-0.114-4.285-2.88-8.357-7.094-10.464c-4.242-2.071-9.166-1.809-12.613,0.738L4.008,182.45c-3.05,2.221-4.498,5.831-3.86,9.577
		c0.61,3.759,3.249,7.143,6.966,8.974l35.722,17.629c-1.937,12.166-3.018,24.602-3.018,37.279
		c-0.014,65.102,26.475,124.31,69.153,166.944C151.607,465.525,210.8,492.013,275.91,492
		c65.095,0.014,124.302-26.475,166.937-69.146c42.678-42.635,69.167-101.842,69.154-166.944
		C512.014,192.446,486.844,134.565,446.025,92.206z"></path>
</g>
</a>
</div>
<div id="main">
<div id="contents"></div>
<a id="next">${t.next}</a>
</a>
</div>
`;
            document.body.appendChild(this.$container);

            this.$sub = this.$container.querySelector("#sub");
            this.$contents = this.$container.querySelector("#contents");
            this.$next = this.$container.querySelector("#next");
            this.$reload = this.$container.querySelector(".reload");

            // events

            this.$container.addEventListener("click", ev => {
                if (ev.target == this.$container || !ev.target.tagName == "A") {
                    this.close();
                    return;
                }
            });

            this.$sub.querySelectorAll(".filter:not(.no-count)").forEach($filter =>{
                var filter = customFilters[$filter.getAttribute("filter")];
                if (filter) {
                    $filter.filter = filter;
                    $filter.addEventListener("click", ev => this.filterByCategory(filter));
                }
            });

            /*
            this.$sub.querySelector("[filter=favorite]").addEventListener("click", async ev => {
                var enabled = this.config.favorite = !this.config.favorite;
                storage.archive.options = this.config;

                ev.target.classList.toggle("enabled", enabled);
                var streams = await this.updateStreams({}, true);
                if (streams) {
                    this.drawStreams(streams);
                }
            });*/

            this.$livesFilter = this.$container.querySelector("#sub [filter=lives]");
            this.$livesFilter.addEventListener("click", async ev =>{
                var enabled = this.config.lives = !this.config.lives;
                storage.archive.options = this.config;

                this.$livesFilter.classList.toggle("enabled", enabled);
                var streams = await this.updateStreams({}, true);
                if (streams) {
                    this.drawStreams(streams);
                }
            });

            this.$next.addEventListener("click", async ev => {
                try{
                    this.$next.classList.add("hidden");
                    await this.continue();
                } finally {
                    this.$next.classList.remove("hidden");
                }
            });

            this.$reload.addEventListener("click", async ev => {
                try {
                    var streams = await this.updateStreams({}, true);
                    if (streams) {
                        this.drawStreams(streams);
                    }
                } finally {
                    this.$reload.classList.add("hidden");
                }
            });
        }

        this.createStream = function (data) {
            // console.log(data);

            try {
                var $stream = document.createElement("a");
                $stream.data = data;
                $stream.classList.add("hololive-stream");
                $stream.setAttribute("stream-status", data.status);
                $stream.setAttribute("topic", data.topic_id);

                var site = "";
                // サムネサイズダウン、Youtube以外のサムネURL取得
                var thumbnail = data.thumbnail, href;
                if (data.type == "placeholder") {
                    // twitch
                    if (data.link.indexOf("https://twitch.tv/") == 0) {
                        thumbnail = thumbnail.replace("1920x1080", "426x240");
                        site = "twitch";
                    }
                    // twitter
                    if (thumbnail.indexOf("https://pbs.twimg.com/") == 0) {
                        thumbnail = thumbnail.replace("name=large", "name=small");
                        site = "twitter";
                    }

                    href = data.link;
                    $stream.target = "_blank";
                } else if (data.type == "stream") {
                    thumbnail = `https://i.ytimg.com/vi/${data.id}/mqdefault.jpg?${new Date(data.published_at).getTime()}`;
                    href = "/watch/" + data.id;
                }
                data.thumbnail = thumbnail;

                // トピック
                var topic = "";
                var title = data.title;
                if (data.topic_id) {
                    topic = t.topic[data.topic_id.toLowerCase()] || data.topic_id.replace(/_/g, " ");
                } else {
                    var m = data.title.match(/^【([^】]*?)】/) || data.title.match(/^≪([^≫]*?)≫/) || data.title.match(/(#.*?)\s/);
                    if (m) {
                        topic = m[1].trim();
                    }
                }
                title = title.replace(/【[^】]*?】/g, "");
                title = title.replace(/≪[^≫]*?≫/g, "");
                title = title.replace(/(※.*?|※?\s?)(ネタバレ(あり)?|spoiler alert)/gi, "");
                title = title.trim();

                var name = data.channel.name;

                // 配信時間
                var hours, mins, secs, duration = data.duration;
                //  var time = this.toLiveDate(data.start_actual, data.start_scheduled, data.ended, data.published_at);
                if (data.status == "live") {
                    duration = Math.floor((new Date().getTime() - new Date(data.available_at).getTime()) / 1000);
                }

                hours = Math.floor(duration / 60 / 60);
                mins = ("00" + Math.floor(duration / 60 % 60)).slice(-2);
                secs = ("00" + Math.floor(duration % 60)).slice(-2);

                var icon = data.channel.photo.replace("=s800", "=s144");
                $stream.href = href;
                $stream.innerHTML = `
<div class="thumbnail" style="background-image: url('${thumbnail}')">
    <div class="date">${hours}:${mins}:${secs}</div>
    <div class="topic">${topic}</div>
    <div class="site" class="${site}"></div>
</div>
<div class="title-wrapper"><div class="title">${title}</div><div class="name">${name}</div></div>
<div class="photo" style="background-image: url('${icon}');" />
`;
                $stream.addEventListener("mouseenter", this.previewStream, false);
            } catch(ex) {
                console.log(ex);
            }
            return $stream;
        }

        this.updateLiveDate = function () {
            if (!this.$contents) return;

            this.$contents.querySelectorAll(".hololive-stream[stream-status=live]").forEach(e => {
                var duration = Math.floor((new Date().getTime() - new Date(e.data.available_at).getTime()) / 1000);
                var hours = Math.floor(duration / 60 / 60);
                var mins = ("00" + Math.floor(duration / 60 % 60)).slice(-2);
                var secs = ("00" + Math.floor(duration % 60)).slice(-2);
                e.querySelector(".date").innerText = `${hours}:${mins}:${secs}`;
            });
        }

        this.drawStreams = function (newStreams, isAppend) {
            this.archive = isAppend ? this.archive.concat(newStreams) : newStreams;

            if (!isAppend) {
                removeAllChildNodes(this.$contents);
            }
            newStreams
                // .filter(data => !(data.status == "upcoming" && new Date().getTime() - new Date(data.available_at).getTime() > 0)) // fix to status bug in holodex
                .forEach(data => self.$contents.appendChild(self.createStream(data)));

            this.countCategory();
            this.filterByCategory();

            if (!isAppend) {
                this.$reload.classList.add("hidden");
                this.shouldReloadTimer = clearTimeout(this.shouldReloadTimer);
                this.shouldReloadTimer = setTimeout(() => this.$reload.classList.remove("hidden"), 180 * 1000);
            }
        }

        this.open = async function () {
            this.config = Object.assign({
                lives: false,
                favorite: false,
            }, storage.archive.options);

            this.$container.classList.remove("hidden");
            this.$reload.classList.add("hidden");
            this.updateLiveDateTimer = setInterval(() => this.updateLiveDate(), 1000);

            var streams = await this.updateStreams({}, false).catch(ev => console.log(ev));
            if (streams) {
                this.drawStreams(streams, false);
            }
        }

        this.filterByCategory = function (newFilter) {
            if (newFilter) {
                this.filter = newFilter;
            }

            this.$contents.querySelectorAll(".hololive-stream").forEach($s => {
                $s.classList.toggle("hidden", !$s.hasAttribute(`category-${this.filter.name}`));
            });
        }

        this.countCategory = function () {
            // フィルタ結果を属性にキャッシュ
            var $streams = this.$contents.querySelectorAll(".hololive-stream:not(.counted)");
            $streams.forEach($s => {
                for (var key in customFilters) {
                    var filter = customFilters[key];
                    if (filter.match($s.data)) {
                        $s.setAttribute(`category-${filter.name}`, true);
                    }
                }
                $s.classList.add("counted");
            });

            this.$sub.querySelectorAll(".filter:not(.no-count)").forEach($f => {
                var filterName = $f.getAttribute("filter");
                $f.setAttribute("count", this.$contents.querySelectorAll(`[category-${filterName}=true]`).length);
            });
        }

        this.continue = async function () {
            var newStreams = await this.updateStreams({
                offset: this.archive.length,
            }, true);

            if (newStreams) {
                this.drawStreams(newStreams, true);
            }
        }

        this.close = function () {
            this.$container.classList.add("hidden");
            this.updateLiveDateTimer = clearInterval(this.updateLiveDateTimer);
            this.shouldReloadTimer = clearTimeout(this.shouldReloadTimer);
        }

        //

        this.updateStreams = async function (options, isForce = false) {
            try {
                if (this.isRefreshing) return false;
                this.isRefreshing = true;

                // 十分に新しい、HoloDexから更新の必要はない
                if (!isForce && Date.now() - this.lastUpdated < REFRESH_INTERVAL) {
                    return false;
                }

                if (this.config.lives) {
                    options.status = "past,missing,live,placeholder";
                }

                var response = await holodex.getVideos(options, this.config.favorite).catch(ex => {
                    console.log("cache", ex);
                });

                if (response && Array.isArray(response.items)) {
                    this.lastUpdated = Date.now();

                    return response.items;
                }

                // console.log(response);

                return false;
            } catch (ex) {
                console.log(ex);

                return false;
            } finally {
                this.isRefreshing = false;
            }
        }

        // utils

    }

    function GenFilter(name, channels) {
        this.name = name;
        this.channels = channels || [];
        this.match = stream => this.channels.indexOf(stream.channel.id) >= 0;
    }

    function CategoryFilter(name, category, freewords) {
        this.name = name;
        this.category = Array.isArray(category) ? category : [ category ];
        this.category = this.category.map(c => c.toLowerCase());
        this.freewords = Array.isArray(freewords) ? freewords : [ freewords ];

        this.match = (stream => {
            if (this.category && this.category.indexOf(String(stream.topic_id).toLowerCase()) >= 0) {
                return true;
            }
            if (this.freewords && this.freewords.find(w => stream.title.match(w))) {
                return true;
            }
            return false;
        });
    }

    function Filter(name, func) {
        this.name = name;
        this.func = func;
        this.match = stream => func(stream);
    }

    function initilizeFilters() {
        var filters = {};

        filters.all = new Filter("all", () => true);

        filters.jp = new GenFilter("jp", [
            // hololive official
            "UCJFZiqLMntJufDCHc6bQixg",
            // jp0
            "UC0TXe_LYZ4scaW2XMyi5_kw",
            "UC5CwaMl1eIgY8h02uZw7u8A",
            "UCDqI2jOz0weumE8s7paEk6g",
            "UC-hM6YJuNYVAmUWxeIr9FeA",
            "UCp6993wxpyDPHUpavwDFqgg",
            // jp1
            "UC1CfXB_kRs3C-zaeTG3oGyg",
            "UCD8HOxPs4Xvsm8H0ZxXGiBw",
            "UCdn5BQ06XqgXoAxIhbqw5Rg",
            "UCFTLzh12_nrtzqBPsTCqenA",
            "UCHj_mh57PVMXhAUDphUQDFA",
            "UCLbtM3JZfRTg8v2KGag-RMw",
            "UCQ0UDLQCjY0rmuxCDE38FGg",
            // jp2
            "UC1opHUrw8rvnsadT-iGp7Cg",
            "UC1suqwovbL1kzsoaZgFZLKg",
            "UC7fk0CB07ly8oSl0aqKkqFg",
            "UCp3tgHXw_HI0QMk1K8qh3gQ",
            "UCvzGlP9oQwU--Y0r9id_jnA",
            "UCXTpFs_3PqI41qX2d9tL2Rw",
            // gamers
            "UCdn5BQ06XqgXoAxIhbqw5Rg",
            "UChAnqc_AY5_I3Px5dig3X1Q",
            "UCp-5t9SrOQwXMU7iIjQfARg",
            "UCvaTdHTWBGv3MKj3KVqJVCw",
            // jp3
            "UC1DCedRgGHBdm81E1llLhOQ",
            "UCCzUftO8KOVkV4wQG1vkUvg",
            "UCdyqAaZDKHXg4Ahi7VENThQ",
            "UCvInZx9h3jC2JzsIzoOebWg",
            // jp4
            "UC1uv2Oq6kNxgATlCiez59hw",
            "UCa9Y57gfeY0Zro_noHRVrnw",
            "UCqm3BQLlJfvkTsX_hvm0UmA",
            "UCZlDXzGoo7d44bwdNObFacg",
            // jp5
            "UCAWSyEs_Io8MtpY3m-zqILA",
            "UCFKOVgVbGmX65RxO3EtH3iw",
            "UCK9V2B22uJYu3N7eR_BT9QA",
            "UCUKD-uaobj9jiqB-VXt71mA",
            // holox
            "UC6eWCld0KwmyHFbAqK3V-Rw",
            "UCENwRMx5Yh42zWpzURebzTw",
            "UCIBY1ollUsauvVi4hW4cumw",
            "UCs9_O1tRPMQTHQ-N_L6FU2g",
            "UC_vMYWcDjmfdpH6r4TTn1MQ",
        ]);

        filters.en = new GenFilter("en", [
            // en offical
            "UCotXwY6s8pWmuWd_snKYjhg",
            //enmyth
            "UCHsx4Hqa-1ORjQTh9TYDhww",
            "UCL_qhgtOy0dy1Agp8vkySQg",
            "UCMwGHR0BTZuLsmjY_NT5Pwg",
            "UCoSrY_IQQVpmIRZ9Xf-y93g",
            "UCyl1z3jo3XHR1riLFKG5UAg",
            // enhope
            "UC8rcEBzJSleTkf_-agPM20g",
            // encouncil
            "UC3n5uGu18FoCy23ggWWp8tA",
            "UCgmPnx-EEeOrZSg5Tiw7ZRQ",
            "UCmbs8T6MWqUHP1tIQvSgKrg",
            "UCO_aKKYxn4tvrqPjcTzZ6EQ",
            "UCsUj0dszADCGbF3gNrQEuSQ",
            // en advant
            "UC9p_lqQ0FEDz327Vgf5JwqA",
            "UC_sFNM0z0MWm9A6WlKPuMMg",
            "UCt9H_RpQzhxzlyBxFqrdHqA",
            "UCgnfPPb9JI3e9A4cXHnWbyg",
            // en shorts
            "UCNoxM_Kxoa-_gOtoyjbux7Q",
            // en advant
            "UCt9H_RpQzhxzlyBxFqrdHqA", // "@FUWAMOCOch",
            "UCgnfPPb9JI3e9A4cXHnWbyg", // "@ShioriNovella",
            "UC9p_lqQ0FEDz327Vgf5JwqA", // "@KosekiBijou",
            "UC_sFNM0z0MWm9A6WlKPuMMg", // "@NerissaRavencroft",
        ]);

        filters.id = new GenFilter("id", [
            // id1
            "UCAoy6rzhSf4ydcYjJw3WoVg",
            "UCfrWoRGlawPQDQxxeIDRP0Q",
            "UCOyYb1c43VlX9rc_lT6NKQw",
            "UCP0BspO_AMEe3aQqqpo89Dg",
            // id2
            "UC727SQYUvx5pDDGQpTICNWg",
            "UChgTyjG-pdNvxxhdsXfHQ5Q",
            "UCYz_5n-uDuChHtLo7My1HnQ",
            // id3
            "UCjLEmnpCNeisMxy134KPwWw",
            "UCTvHWSfBZgtxE4sILOaurIQ",
            "UCZLZ8Jjx_RN2CXloOmgTHVg"
        ]);

        filters.devis = new GenFilter("devis", [
            // official
            "UC10wVt6hoQiwySRhz7RdOUA", // "@hololiveDEV_IS"
            // regloss
            "UCMGfV7TVTmHhEErVJg1oHBQ", // "@HiodoshiAo",
            "UCWQtYtq9EOB4-I5P-3fh8lA", // "@OtonoseKanade",
            "UCtyWhCj3AqKh2dXctLkDtng", // "@IchijouRirika",
            "UCdXAk5MpyLD8594lm_OvtGQ", // "@JuufuuteiRaden",
            "UC1iA6_NT4mtAcIII6ygrvCw", // "@TodorokiHajime",
        ]);

        filters.stars = new GenFilter("stars", [
            // official stars
            "UCWsfcksUUpoEvhia0_ut0bA",
            // stars1
            "UC6t3-_N8A6ME1JShZHHqOMw",
            "UC9mf_ZVpouoILRY9NUIaK-w",
            "UCKeAhJvy8zgXWbh9duVjIaQ",
            "UCZgOv3YDEs-ZnZWDYVwJdmA",
            // stars2
            "UCANDOlYTJT7N5jlRC3zfzVA",
            "UCGNI4MENvnsymYjKiZwv9eg",
            "UCNVEsYbiZjH5QLmGeSgTSzg",
            // stars3
            "UChSvpZYRPh0FvG4SJGSga3g",
            "UCwL7dgTxKo8Y4RFIKWaf8gA",
            // stars4
            "UCc88OV45ICgHbn3ZqLLb52w",
            "UCdfMHxjcCc2HSd9qFvfJgjg",
            "UCgRqGV1gBf2Esxh0Tz1vxzw",
            "UCkT1u65YS49ca_LsFwcTakw",
        ]);

        filters.stars_en = new GenFilter("stars_en", [
            // stars en official
            "UCJxZpzx4wHzEYD-eCiZPikg",
            // tempus
            "UCyxtGMdWlURZ30WSnEjDOQw",
            "UC2hx0xVkMoHGWijwr_lA01w",
            // tempus 2
            "UCHP4f7G2dWD4qib7BMatGAw",
            "UC060r4zABV18vcahAWR1n7w",
            "UC7gxU6NXjKF1LrgOddPzgTw",
            "UCMqGG8BRAiI1lJfKOpETM_w",
            // armis
            "UCajbFh6e_R8QZdHAMbbi4rQ",
            "UCJv02SHZgav7Mv3V0kBOR8Q",
            "UCLk1hcmxg8rJ3Nm1_GvxTRA",
            "UCTVSOgYuSWmNAt-lnJPkEEw",
        ]);

        filters.stars_all = new GenFilter("stars_all", [].concat(filters.stars.channels, filters.stars_en.channels));

        filters.outside = new Filter("outside", (() => {
            var allChannels = [].concat(filters.jp.channels, filters.en.channels, filters.id.channels, filters.devis.channels, filters.stars.channels, filters.stars_en.channels);
            return s => allChannels.indexOf(s.channel.id) == -1;
        })());

        filters.singing = new CategoryFilter("singing", ["singing"]);

        filters.talk = new CategoryFilter("talk", [
            "asmr","camera_stream","cooking_stream","drawing","drawing_personality_test","duolingo","eat","english_lesson","english_only",
            "japanese_lesson","japanese_only","mashmallow","morning","review","talk","totus","trpg","vlog","watchalong"
        ]);

        filters.recommend = new CategoryFilter("recommend", [
            "announce", "celebration", "3d_stream", "debut_stream", "birthday", "anniversary", "totsu", "outfit_reveal"]);

        filters.membersonly = new CategoryFilter("membersonly", "membersonly", ["メン限", "メンバー限定"]);

        filters.rpg = new CategoryFilter("rpg", [
            "13_sentinels:_aegis_rim","armored_core","assassings_creed","bioshock","bloodborne","cyberpunk_2077",
            "dark_souls","dead_space","death_stranding","demon's_soul","detroit:_become_human","devil_may_cry","dragon_ball_z_:_kakarot","dragon's_dogma","dragon_quest","dying_light",
            "earthbound","elden_ring","elder_scrolls",
            "fate/samurai_remnant","fallout","final_fantasy","final_fantasy_online","fire_emblem","fire_emblem",
            "ghostwire: tokyo","gta","gta5",
            "hogwarts_legacy","judgment","live_a_live",
            "mafia","metal_gear","metal_gear","nier","no_man's_sky",
            "one_piece_odyssey","persona","persona","pokemon","sekiro","starfield",
            "tales_of","undertale","undertale","yakuza","yokai_watch","witcher3","zelda",
        ]);

        filters.pvp = new CategoryFilter("pvp", [
            "apex","call_of_duty","counter-strike",
            "darker_and_darker","dbd","deltarune","dota","fallguys","fortnite","guilty_gear",
            "league_of_legends","mariokart","mahjong",
            "overwatch","pupg","rainbow_six",
            "slither.io","smash","soulcalibur","splatoon","street_fighter","tarkov","tarkov","tetris","tetris",
            "valorant","yu-gi-oh!"
        ]);

        filters.coop = new CategoryFilter("coop", [
            "7_days_to_die","a_way_out","among_us","animal_crossing","ark","back4blood","bokura","content_warning","craftopia",
            "devour","fifa","gartic_phone","ghost_exorism_inc.",
            "hacktag","hide_and_shriek","human_fall_flat","it_takes_two","keep_talking_nobody_explode",
            "l4d2","lethal_company","mario_party","minecraft","monhan","monopoly",
            "operation_tango","overcooked","phasmophobia","plateup","project_winter","raft","rust",
            "school_labyrinth","super_bunny_man","terraria","ultimate_chicken_horse","uno","unrailed","unravel_two",
            "we_were_here"
        ]);

        filters.horror = new CategoryFilter("horror", [
            "ao_oni","convenience_store","devour",
            "fatal_frame","friday_night_funkin'","ghost_room",
            "ib","imi_ga_wakaru_to_kowai_manga","inunaki_tunnel",
            "little_nightmares","night_deliverry","night_security",
            "outlast","poppy_playtime","parasocial","phasmophobia","residentevil",
            "school_labyrinth","silent_hill","shadow_corridor","shinkansen_0",
            "the_closing_shift","the_karaoke","the_working_dead","tsugunohi","visage","yomawari"
        ]);

        filters.hololive = new CategoryFilter("hololive", [
            "holocure","hololiveerror","holoparade","holo_x_break","idle_showdown","miko_in_maguma","wowowow_korone_box",
        ], ["Miko In Maguma","WOWOWOW KORONE"]);

        filters.video = new Filter("video", (() => {
            var category = ["shorts", "music_covor", "original_song", "dancing", "animation"];
            return stream => {
                if (category.indexOf(String(stream.topic_id).toLowerCase()) >= 0) {
                    return true;
                }
                if (stream.start_actual && stream.start_scheduled && stream.available_at &&
                    stream.available_at == stream.start_actual == stream.start_scheduled) {
                    return true;
                }
                if (0 < stream.duration && stream.duration < 60 * 6) {
                    return true;
                }
                return false;
            }
        })());

        return filters;
    }

    // @see https://holodex.stoplight.io/docs/holodex/YXBpOjExNjIwMjM0-holodex-holo-api-v2
    function Holodex() {
        this.createXHRData = function (options) {
            var data = "";
            for (var key in options) {
                var value = options[key];
                if (Array.isArray(value)) {
                    value += value.join(",");
                }
                data += `${encodeURIComponent(key)}=${encodeURIComponent(value)}&`;
            }
            return data;
        }

        this.XHR = function (api, data) {
            // console.log(api, data);
            return new Promise((resolve, reject) => {
                var options = {
                    method: "GET",
                    url: api,
                    headers: {
                        "X-APIKEY": atob('NjYxZjdmOGYtZTM1My00ZDMzLTk2ZjgtMTg3ZTU3NGQ2MmQw'),
                        "user-agent": `${APPNAME}/${VERSION}`,
                    },
                    onload: (context) => {
                        try {
                            resolve(JSON.parse(context.responseText));
                        } catch (ex) {
                            reject(context);
                        }
                    },
                    onerror: reject,
                    onabort: reject,
                    ontimeout: reject,
                };

                if (data) {
                    options.method = "POST";
                    options.data = data.replace("&20", "+");
                    options.headers.application = "x-www-form-urlencoded";
                }

                GM.xmlHttpRequest(options);
            });
        }

        this.getLive = function(options = {}, isUser = false) {
            options = Object.assign({
                type: "placeholder,stream",
                org: "Hololive",
                lang: window.navigator.language == "ja" ? "ja" : "en",
            }, options);

            isUser = false;
            // https://holodex.net/api/v2/live?type=placeholder%2Cstream&org=Hololive
            return this.XHR(`https://holodex.net/api/v2/${isUser ? "users/" : ""}live?${this.createXHRData(options)}`);
        }

        this.getTopics = function () {
            return this.XHR(`https://holodex.net/api/v2/topics`);
        }

        this.getVideos = function(options = {}, isUser = false) {
            options = Object.assign({
                //
                // past, missing, live, placeholder
                status: "past,missing",
                // stream, clip
                type: "stream",
                paginated: false,
                to: new Date().toISOString(),
                org: "Hololive",
                // asc,desc
                // order: "desc",
                // <=50
                limit: 50,
                offset: 0,
                // clips,refers,sources,simulcasts,mentions,description,live_info,channel_stats,songs
                // includes: "live_info,refers",
                // en,ja
                // lang: "all",
                lang: window.navigator.language == "ja" ? "ja" : "en",
            }, options);

            // users api dont allow
            isUser = false;

            return this.XHR(`https://holodex.net/api/v2/${isUser ? "users/" : ""}videos?${this.createXHRData(options)}`);
        }

        this.getChannels = function () {
            var options = {
                org: "Hololive",
                limit: 50,
                offset: 0,
            };
            return this.XHR(`https://holodex.net/api/v2/channels?${this.createXHRData(options)}`);
        }
    }
})();