Greasy Fork is available in English.

Youtube/Holotools, Display Hololive live streaming on youtubve

You can check schedule of recently hololive stream on youtube

目前為 2021-07-07 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Youtube/Holotools, Display Hololive live streaming on youtubve
// @name:ja      Youtube/Holotools, ホロライブ配信一覧をYoutubeで表示
// @namespace    http://tampermonkey.net/
// @version      0.1.8
// @description  You can check schedule of recently hololive stream on youtube
// @description:ja ホロライブの直近のスケジュールをYoutubeで確認できます
// @author       You
// @match        https://www.youtube.com*
// @match        https://www.youtube.com/*
// @match        https://hololive.jetri.co/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// @noframes
// @unwrap
// ==/UserScript==

(function() {
    'use strict';
    const VERSION = "0.1.8";
    const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 mins
    const FREECHAT_SCHEDULE = 7 * 24 * 60 * 60 * 1000; // 7 days
    const ARCHIVE_ENDHOURS = 36 * 60 * 60 * 1000; // 36 hours
    const ERROR_TIMEOUT = 3000; // 3 secs
    const holotoolsHideChannels = "holotoolsHideChannels";

    const storage = {
        schedule: {
            get updated() { return catchParseError(() => parseInt(localStorage.ht_s_updated), 0); },
            set updated(value) { localStorage.ht_s_updated = new String(value); },
            get cache() { return catchParseError(() => JSON.parse(localStorage.ht_s_cache), null); },
            set cache(value) { localStorage.ht_s_cache = JSON.stringify(value); },
            clear: () => {
                localStorage.ht_s_updated = 0;
                delete localStorage.ht_s_cache;
            }
        },
        archive: {
            get updated() { return catchParseError(() => parseInt(localStorage.ht_a_updated), 0); },
            set updated(value) { localStorage.ht_a_updated = new String(value); },
            get cache() { return catchParseError(() => JSON.parse(localStorage.ht_a_cache), null); },
            set cache(value) { localStorage.ht_a_cache = JSON.stringify(value); },
            clear: () => {
                localStorage.ht_a_updated = 0;
                delete localStorage.ht_a_cache;
            }
        },
    }

    var isRefreshing = false,
        scheduleUpdated = 0,
        archiveUpdated = 0;

    if (location.href.indexOf("hololive.jetri.co") > 0) {
        GM_setValue(holotoolsHideChannels, localStorage.hideChannels);
        return;
    }

    var hideChannels;
    try {
        hideChannels = JSON.parse(GM_getValue(holotoolsHideChannels) || "[]") || [];
    } catch {
        hideChannels = [];
    }

    // console.log(hideChannels);

    initilize();

    return;

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

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

        buildScheduleBase();
    }

    function onScrollContainer(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 contents = document.querySelector("#hololive-schedule #contents"),
              lp = parseFloat(contents.style.left) || 0,
              dw = document.documentElement.clientWidth,
              cw = contents.clientWidth;

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

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

        return false;
    }

    function buildScheduleBase() {
        const style = document.createElement("style");
        style.id = "hololive-schedule-style";
        style.innerHTML = `
#hololive-schedule {
    --background-color: #ffffff;
    --icon-hover-background-color: #d9d9d9;
    --icon-fill: #212121;
}
[dark=true] #hololive-schedule {
    --background-color: #212121;
    --icon-hover-background-color: #4c4c4c;
    --icon-fill: #ffffff;
}

#hololive-schedule {
    position: fixed;
    bottom: 0;
    left: 0;
    min-width: 100%;
    opacity: 0;
    scrollbar-width: none;
    box-sizing: border-box;
    z-index: 20000;
    transition: opacity .1s linear;
}
#hololive-schedule:hover {
    opacity: 1;
}
.no-scroll #hololive-schedule {
    pointer-events: none;
}
.no-scroll #hololive-schedule.visible {
    pointer-events: unset;
}
#hololive-schedule #contents {
    position: absolute;
    bottom: 0;
    left: 0;
    min-width: 100%;
    max-height: 60px;
    padding: 8px 32px 8px 0;
    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 #contents {
    max-height: 200px;
    scrollbar-width: none;
}
#hololive-schedule #contents::-webkit-scrollbar {
    display: none;
}
.hololive-stream {
    display: inline-block;
    position: relative;
    border: solid 3px;
    border-raduis: 3px;
    font-size: 10px;
    margin: 0 0 0 8px;
}
.hololive-stream.hide-channel {
    display: none;
}
.hololive-stream .thumbnail {
    vertical-align: middle;
    width: 128px;
    height: 72px;
    background-size: contain;
    background-repeat: 1;
    background-position: center center;
    opacity: 0;
    transition: opacity 0.1s ease;
}
.hololive-stream .thumbnail.loaded {
    opacity: 1;
}
.hololive-stream .title-wrapper {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    overflow: hidden;
    color: #202020;
    background: #fffa;
    z-index: 1;
}
.hololive-stream:hover .title {
    animation: textAnimation 4s linear infinite;
}
@keyframes textAnimation {
    0% { margin-left: 0; }
    100% { margin-left: -350%; }
}
.hololive-stream .date {
    position: absolute;
    padding: 1px 3px;
    color: #202020;
    background: #fffd;
    font-weight: bold;
    z-index: 1;
}
.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-stream .photo.loaded {
    opacity: 1;
}
.hololive-stream.live,
.hololive-stream.live .photo {
    border-color: crimson;
}
.hololive-stream.upcoming,
.hololive-stream.upcoming .photo {
    border-color: deepskyblue;
}
.hololive-stream.ended,
.hololive-stream.ended .photo {
    border-color: gray;
}
.hololive-stream.dummy {
    pointer-events: none;
}

#hololive-schedule #tools {
    display: flex;
    flex-direction: column;
    position: absolute;
    right: 0;
    bottom: 0;
    width: 32px;
    height: 97px;
    z-index: 2;
    transition: opacity .2s linear, width .1s linear;
}
#hololive-schedule:hover #tools {
    max-height: 200px;
    opacity: 1;
}
#hololive-schedule #tools:hover {
    width: 60px;
}
#hololive-schedule #tools a {
    position: relative;
    display: block;
    flex-grow: 1;
    cursor: pointer;
    vertical-align: middle;
    text-align: center;
    background: linear-gradient(to right, #fff0 0%, var(--background-color) 30%, var(--background-color) 100%);
    transition: background .1s linear, padding .1s linear;
}
#hololive-schedule #tools a:hover {
    background: linear-gradient(to right, #fff0 0%, var(--icon-hover-background-color) 30%, var(--icon-hover-background-color) 100%);
}
#hololive-schedule #tools a svg {
    display: inlnie-block;
    height: 100%;
}
#hololive-schedule #tools a svg .shape {
    fill: var(--icon-fill);
    transition: fill .1s linear;
}
#hololive-schedule #tools a:hover svg .shape {
}
#hololive-schedule #tools #powered {
    display: block;
    position: absolute;
    width: 140px;
    top: -24px;
    right: 0;
    height: 24px;
    line-height: 24px;
    background-color: var(--background-color);
    color: var(--icon-fill);
    border-radius: 5px 0 0 0;
}

#hololive-schedule #contents .contents.hidden {
    display: none;
    width: 0;
}

#hololive-schedule #thumbnail-preview {
    position: fixed;
    width: 512px;
    height: 288px;
    bottom: 144px;
    background-size: cover;
    border-radius: 4px;
    z-index: 9;
    pointer-events: none;
    box-shadow: #000 0px 0px 12px;
}
#hololive-schedule #thumbnail-preview.hidden {
    opacity: 0;
}
#hololive-schedule #title-preview {
    position: fixed;
    bottom: 100px;
    font-size: 20px;
    padding: 6px 12px;
    border-radius: 4px;
    color: #303030;
    background: #efefef;
    z-index: 10;
    pointer-events: none;
    transition: opacity 0.1s ease;
}
#hololive-schedule #title-preview.hidden {
    opacity: 0;
}
`;
        document.body.appendChild(style);

        const localize = {
            ja: {
                schedule: "スケジュールを見る",
                archive: "終了した放送を見る",
                powered: "参照元: Holotools",
            },
            en: {
                schedule: "View scheulde",
                archive: "View finished streaming",
                powered: "Reference: Holotools",
            }
        }
        const t = localize[window.navigator.language] || localize.en;

        const container = document.createElement("div");
        container.id = "hololive-schedule";
        container.onmouseenter = onMouseEnter;
        container.innerHTML = `
<div id="tools">
<a id="schedule" title="${t.schedule}">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="24px" height="24px" viewBox="0 0 512 512" style="padding: 2px">
	<path class="shape" d="M256,0C114.625,0,0,114.625,0,256c0,141.374,114.625,256,256,256c141.374,0,256-114.626,256-256
		C512,114.625,397.374,0,256,0z M351.062,258.898l-144,85.945c-1.031,0.626-2.344,0.657-3.406,0.031
		c-1.031-0.594-1.687-1.702-1.687-2.937v-85.946v-85.946c0-1.218,0.656-2.343,1.687-2.938c1.062-0.609,2.375-0.578,3.406,0.031
		l144,85.962c1.031,0.586,1.641,1.718,1.641,2.89C352.703,257.187,352.094,258.297,351.062,258.898z"></path>
</svg>
</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://hololive.jetri.co/" target="blank_">${t.powered}</a>
</div>
<div id="contents" style="left: 0px;">
    <div id="schedule" class="contents hidden"></div>
    <div id="archive" class="contents hidden"></div>
    <div id="dummy" class="contents"></div>
</div>
<div id="title-preview" class="hidden"></div>
<div id="thumbnail-preview" class="hidden"></div>
`;
        document.body.appendChild(container);

        container.addEventListener("wheel", onScrollContainer, true);
        document.querySelector("ytd-app").addEventListener("scroll", (e) => {
            container.classList.toggle("visible", e.target.scrollTop > 80);
        });

        container.querySelector("#tools #schedule").addEventListener("click", (ev) => refreshSchedule(ev.ctrlKey));
        container.querySelector("#tools #archive").addEventListener("click", (ev) => refreshArchive(ev.ctrlKey));
        container.addEventListener("mouseenter", (ev) => {
            if (ev.target.classList.contains("hololive-stream")) {
                const title = document.querySelector("#hololive-schedule #title-preview");
                // 左端に合わせておかないと長いタイトルで改行が含まれる場合に
                // clientWidth の値が小さくなって中央寄せに失敗する
                title.style.left = "0px";
                title.innerText = ev.target.querySelector(".title").innerText;
                title.classList.remove("hidden");
                title.style.left = `${document.body.clientWidth / 2 - title.clientWidth / 2}px`;

                const thumbnail = document.querySelector("#hololive-schedule #thumbnail-preview");
                thumbnail.style.backgroundImage = ev.target.querySelector(".thumbnail").style.backgroundImage;
                thumbnail.classList.remove("hidden");
                thumbnail.style.left = `${document.body.clientWidth / 2 - thumbnail.clientWidth / 2}px`;
            }
        }, true);
        container.addEventListener("mouseleave", (ev) => {
            document.querySelector("#hololive-schedule #thumbnail-preview").classList.add("hidden");
            document.querySelector("#hololive-schedule #title-preview").classList.add("hidden");
        });
        drawLoadingDummy();
    }

    function onMouseEnter() {
        if (isRefreshing) return;

        if (document.querySelector("#hololive-schedule #contents #archive.hidden")) {
            refreshSchedule();
        } else {
            refreshArchive();
        }
    }

    // 枠をつくる
    function createStream(details) {
        // console.log(details);
        var stream = document.createElement("a");
        stream.data = details;
        stream.classList.add("hololive-stream");
        if (hideChannels.find((x) => x == details.channel.yt_channel_id)) {
            stream.classList.add("hide-channel");
        }
        stream.href = "/watch/" + details.yt_video_key;
        stream.innerHTML = `
<div class="date">${toLiveDate(details.live_schedule, details.live_start, details.live_end, details.published_at)}</div>
<div class="thumbnail" style="background-image: url('https://i.ytimg.com/vi/${details.yt_video_key}/mqdefault.jpg?${new Date(details.live_start || details.live_schedule || details.published_at).getTime()}')"></div>
<div class="title-wrapper"><div class="title">${details.title}</div></div>
<div class="photo" style="background-image: url('${details.channel.photo}');" />
`;

        return stream;
    }

    function drawSchedule(streams) {
        // console.log(streams);

        try {
            const contents = document.querySelector("#hololive-schedule #contents #schedule");
            contents.innerHTML = "";
            contents.style.left = "0px";

            var key, stream;

            if (streams) {
                if (streams.live) {
                    streams.live = streams.live
                        .filter(x => x.status != "missing");
                    streams.live
                        .sort((a, b) => (a.live_start || a.live_schedule) < (b.live_start || b.live_schedule) ? 1 : -1);
                    for (key in streams.live) {
                        stream = createStream(streams.live[key]);
                        stream.classList.add("live");
                        contents.appendChild(stream);
                    }
                }

                if (streams.upcoming) {
                    streams.upcoming = streams.upcoming
                        .filter(x => x.status != "missing")
                        .filter(x => new Date(x.live_schedule).getTime() - new Date().getTime() < FREECHAT_SCHEDULE);
                    streams.upcoming
                        .sort((a, b) => a.live_schedule > b.live_schedule ? 1 : -1);
                    for (key in streams.upcoming) {
                        stream = createStream(streams.upcoming[key]);
                        stream.classList.add("upcoming");
                        contents.appendChild(stream);
                    }
                }

                if (streams.ended) {
                    streams.ended = streams.ended
                        .filter(x => x.status != "missing");
                    streams.ended
                        .sort((a, b) => a.live_start < b.live_start ? 1 : -1);
                    for (key in streams.ended) {
                        stream = createStream(streams.ended[key]);
                        stream.classList.add("ended");
                        contents.appendChild(stream);
                    }
                }
            }

            contents.querySelectorAll(".thumbnail").forEach((element, index) => {
                setTimeout(() => element.classList.add("loaded"), (index + 1) * 25);
            });
            contents.querySelectorAll(".photo").forEach((element, index) => {
                setTimeout(() => element.classList.add("loaded"), (index + 1) * 25);
            });
        } finally {
            toggleDisplay("schedule");
        }
    }

    function drawLoadingDummy() {
        const contents = document.querySelector("#hololive-schedule #contents #dummy");
        contents.innerHTML = "";
        contents.style.left = "0px";
        const data = {
            bb_video_id: null,
            channel:
            {
                bb_space_id: null,
                id: 0,
                name: "Loading",
                photo: "https://www.youtube.com/s/desktop/fe7279a7/img/favicon_144.png",
                published_at: "2000-01-01T00:00:00.000Z",
                subscriber_count: 0,
                twitter_link: "",
                video_count: 0,
                view_count: 0,
                yt_channel_id: "",
            },
            id: 0,
            live_end: null,
            live_schedule: "2222-22-22T22:22:22.222Z",
            live_start: null,
            live_viewers: null,
            status: "upcoming",
            thumbnail: null,
            title: "Comming soon ...",
            yt_video_key: "",
        }
        const dummy = createStream(data);
        dummy.classList.add("dummy");

        for (var i = 0; i < 20; i++) {
            contents.appendChild(dummy.cloneNode(true));
        }

        contents.querySelectorAll(".thumbnail").forEach((element, index) => {
            setTimeout(() => element.classList.add("loaded"), 25);
        });
        contents.querySelectorAll(".photo").forEach((element, index) => {
            setTimeout(() => element.classList.add("loaded"), 25);
        });
    }

    function drawArchive(streams) {
        // console.log(streams);

        try {
            const contents = document.querySelector("#hololive-schedule #contents #archive");
            contents.style.left = "0px";
            contents.innerHTML = "";

            if (streams && streams.videos) {
                streams.videos = streams.videos
                    .filter(x => x.status == "past");
                streams.videos
                    .sort((a, b) => (a.live_end || a.published_at) < (b.live_end || b.published_at) ? 1 : -1);
                for (var key in streams.videos) {
                    const stream = createStream(streams.videos[key]);
                    stream.classList.add("ended");
                    contents.appendChild(stream);
                }
            }

            contents.querySelectorAll(".photo").forEach((element, index) => {
                setTimeout(() => element.classList.add("loaded"), (index + 1) * 25);
            });
            contents.querySelectorAll(".thumbnail").forEach((element, index) => {
                setTimeout(() => element.classList.add("loaded"), (index + 1) * 25);
            });
        } finally {
            toggleDisplay("archive");
        }
    }

    function toggleDisplay(target) {
        document.querySelectorAll(`#hololive-schedule .contents`).forEach((element, index) => {
            element.classList.toggle("hidden", element.id != target);
        });
        document.querySelector("#hololive-schedule #contents").style.left = 0;
    }

    function refreshSchedule(force) {
        //console.log(`refreshSchedule(${force})`);

        if (isRefreshing) return;
        isRefreshing = true;

        if (force) {
            storage.schedule.clear();
            scheduleUpdated = 0;
        }

        try {
            const cacheUpdated = storage.schedule.updated;
            const newestUpdated = Math.max(cacheUpdated, scheduleUpdated);

            //console.log(`c:${cacheUpdated}, s:${scheduleUpdated}`);

            // 十分に新しい
            if (new Date().getTime() - newestUpdated < REFRESH_INTERVAL) {
                // キャッシュのが新しい
                if (cacheUpdated > scheduleUpdated) {
                    const cache = storage.schedule.cache;
                    if (cache) {
                        drawSchedule(cache);
                        scheduleUpdated = cacheUpdated;
                        isRefreshing = false;
                        return;
                    }
                } else {
                    isRefreshing = false;
                    toggleDisplay("schedule");
                    return;
                }
            }
        } catch (ex) {
            console.log(ex);
            storage.schedule.clear();
        }

        toggleDisplay("dummy");

        GM_xmlhttpRequest({
            method: "GET",
            url: "https://api.holotools.app/v1/live?max_upcoming_hours=2190&hide_channel_desc=1",
            headers: { "user-agent": `holotoolsyoutube/${VERSION}` },
            onload: (context) => {
                // console.log(context);
                try {
                    const response = JSON.parse(context.responseText);
                    if (!response.message) {
                        storage.schedule.updated = scheduleUpdated = new Date().getTime();
                        storage.schedule.cache = response;

                        drawSchedule(response);
                    }
                } catch (ex) {
                    console.log(ex, context);
                } finally {
                    isRefreshing = false;
                }
            },
            onerror: setTimeoutToRefresh,
            onabort: setTimeoutToRefresh,
            ontimeout: setTimeoutToRefresh,
        });
    }

    function refreshArchive(force) {
        // console.log("refreshArchive()");
        if (isRefreshing) return;
        isRefreshing = true;

        if (force) {
            storage.archive.clear();
            archiveUpdated = 0;
        }

        try {
            const cacheUpdated = storage.archive.updated;
            const newestUpdated = Math.max(cacheUpdated, archiveUpdated);

            // console.log(`c:${cacheUpdated}, a:${archiveUpdated}`);

            // 十分に新しい
            if (new Date().getTime() - newestUpdated < REFRESH_INTERVAL) {
                // キャッシュのが新しい
                if (cacheUpdated > archiveUpdated) {
                    const cache = storage.archive.cache;
                    if (cache) {
                        drawArchive(cache);
                        archiveUpdated = cacheUpdated;
                        isRefreshing = false;
                        return;
                    }
                } else {
                    isRefreshing = false;
                    toggleDisplay("archive");
                    return;
                }
            }
        } catch (ex) {
            console.log(ex);

            storage.archive.clear();
        }

        const now = new Date().getTime();
        const offset = new Date().getTimezoneOffset() * 60 * 1000;

        const start = new Date();
        start.setTime(now - ARCHIVE_ENDHOURS - offset);
        const end = new Date();
        end.setTime(now - offset);

        // console.log(`${start.toISOString()}${end.toISOString()}`);
        toggleDisplay("dummy");

        GM_xmlhttpRequest({
            method: "GET",
            url: `https://api.holotools.app/v1/videos?start_date=${start.toISOString()}&end_date=${end.toISOString()}&limit=50&sort=live_schedule&order=desc`,
            headers: { "user-agent": `holotoolsyoutube/${VERSION}` },
            onload: (context) => {
                // console.log(context);
                try {
                    var response = JSON.parse(context.responseText);
                    if (!response.message) {
                        storage.archive.updated = archiveUpdated = new Date().getTime();
                        storage.archive.cache = response;
                        drawArchive(response);
                    }
                } catch (ex) {
                    console.log(ex, context);
                } finally {
                    isRefreshing = false;
                }

            },
            onerror: setTimeoutToRefresh,
            onabort: setTimeoutToRefresh,
            ontimeout: setTimeoutToRefresh,
        });
    }

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

    function toLiveDate(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}`;
        }
    }
})();