// ==UserScript==
// @name Youtube/Holotools, Display Hololive live streaming on youtubve
// @name:ja Youtube/Holotools, ホロライブ配信一覧をYoutubeで表示
// @namespace http://tampermonkey.net/
// @version 0.1.6
// @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.6";
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";
var scheduleCache, archiveCache,
isRefreshing = false,
isDisplaySchedule = null;
if (location.href.indexOf("hololive.jetri.co") > 0) {
GM_setValue(holotoolsHideChannels, localStorage.hideChannels);
return;
}
var hideChannels;
try {
hideChannels = JSON.parse(GM_getValue(holotoolsHideChannels) || "[]") || [];
} catch (ex) {
hideChannels = [];
}
// console.log(hideChannels);
initilize();
return;
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),
dw = document.documentElement.clientWidth,
cw = contents.clientWidth;
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";
}
return false;
}
function buildScheduleBase() {
var 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;
}
#hololive-schedule #contents {
position: absolute;
bottom: 0;
left: 0;
min-width: 100%;
max-height: 20px;
padding: 8px 32px 8px 0;
white-space: nowrap;
scrollbar-width: none;
box-sizing: border-box;
transition: left .2s ease, max-height .5s linear;
overflow-y: hidden;
overflow-x: scroll;
background-color: var(--background-color);
}
#hololive-schedule:hover #contents {
max-height: 200px;
scrollbar-width: none;
}
#hololive-schedule #contents::-webkit-scrollbar {
display: none;
}
.no-scroll #hololive-schedule {
pointer-events: none;
}
.no-scroll #hololive-schedule[visible_] {
pointer-events: unset;
}
.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;
}
.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;
}
.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;
height: calc(72px + 16px + 6px);
bottom: 0;
z-index: 2;
max-height: 20px;
width: 32px;
transition: max-height .5s linear, 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 {
}
`;
document.body.appendChild(style);
var localize = {
ja: {
schedule: "スケジュールを見る",
archive: "終了した放送を見る",
},
en: {
schedule: "View scheulde",
archive: "View finished streaming",
}
}
var t = localize[window.navigator.language] || localize.en;
var 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>
</div>
<div id="contents" style="left: 0px;"></div>
`;
document.body.appendChild(container);
container.addEventListener("wheel", onScrollContainer, true);
const contents = container.querySelector("#contents");
document.querySelector("ytd-app").addEventListener("scroll", (e) => {
if (e.target.scrollTop > 100) {
container.setAttribute("visible_", "");
} else {
container.removeAttribute("visible_");
}
});
container.querySelector("#tools #schedule").addEventListener("click", refreshSchedule);
container.querySelector("#tools #archive").addEventListener("click", refreshArchive);
refreshSchedule();
}
function onMouseEnter() {
if (isRefreshing) return;
if (isDisplaySchedule) {
refreshSchedule();
} else {
refreshArchive();
}
}
function createStream(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>
<img class="thumbnail" src="https://i.ytimg.com/vi/${details.yt_video_key}/mqdefault.jpg?${new Date(details.live_start || details.live_schedule || details.published_at).getTime()}">
<div class="title-wrapper"><div class="title">${details.title}</div></div>
<div class="photo" style="background-image: url('${details.channel.photo}');" />
`;
return stream;
}
function updateSchedule(streams) {
// console.log(streams);
try {
const contents = document.querySelector("#hololive-schedule #contents");
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);
}
}
}
} finally {
isDisplaySchedule = true;
}
}
function updateLoadingDummy() {
const contents = document.querySelector("#hololive-schedule #contents");
contents.innerHTML = "";
contents.style.left = "0px";
var 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: "",
}
var dummy = createStream(data);
dummy.classList.add("dummy");
for (var i = 0; i < 20; i++) {
contents.appendChild(dummy.cloneNode(true));
}
}
function updateArchive(streams) {
// console.log(streams);
try {
const contents = document.querySelector("#hololive-schedule #contents");
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 || a.published_at) ? 1 : -1);
for (var key in streams.videos) {
var stream = createStream(streams.videos[key]);
stream.classList.add("ended");
contents.appendChild(stream);
}
}
} finally {
isDisplaySchedule = false;
}
}
function refreshSchedule() {
// console.log("refreshSchedule()");
if (isRefreshing) return;
isRefreshing = true;
try {
var latestUpdated = parseInt(localStorage.holotoolsLatestUpdated);
scheduleCache = JSON.parse(localStorage.holotoolsScheduleCache);
if (new Date().getTime() - latestUpdated < REFRESH_INTERVAL && scheduleCache) {
// console.log("update schedule with cache");
if (isDisplaySchedule != true) {
updateSchedule(scheduleCache);
}
isRefreshing = false;
return;
}
} catch (ex) {
console.log(ex, localStorage.holotoolsScheduleCache);
scheduleCache = null;
delete localStorage.holotoolsScheduleCache;
delete localStorage.holotoolsLatestUpdated;
}
// console.log("refresh");
updateLoadingDummy();
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 {
var response = JSON.parse(context.responseText);
if (!response.message) {
scheduleCache = response;
localStorage.holotoolsScheduleCache = JSON.stringify(scheduleCache);
localStorage.holotoolsLatestUpdated = new Date().getTime();
updateSchedule(scheduleCache);
}
} catch (ex) {
console.log(ex, context);
}
isRefreshing = false;
},
onerror: setTimeoutToRefresh,
onabort: setTimeoutToRefresh,
ontimeout: setTimeoutToRefresh,
});
}
function refreshArchive() {
// console.log("refreshArchive()");
if (isRefreshing) return;
isRefreshing = true;
try {
var latestUpdate = parseInt(localStorage.holotoolsArchiveLatestUpdated);
archiveCache = JSON.parse(localStorage.holotoolsArchiveCache);
if (new Date().getTime() - latestUpdate < REFRESH_INTERVAL && archiveCache) {
// console.log("update schedule with cache");
if (isDisplaySchedule != false) {
updateArchive(archiveCache);
}
isRefreshing = false;
return;
}
} catch (ex) {
console.log(ex, localStorage.holotoolsArchiveCache);
archiveCache = null;
delete localStorage.holotoolsArchiveCache;
delete localStorage.holotoolsArchiveLatestUpdated;
}
var now = new Date().getTime();
var offset = new Date().getTimezoneOffset() * 60 * 1000;
var start = new Date();
start.setTime(now - ARCHIVE_ENDHOURS - offset);
var end = new Date();
end.setTime(now - offset);
// console.log(`${start.toISOString()}${end.toISOString()}`);
updateLoadingDummy();
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) {
archiveCache = response;
localStorage.holotoolsArchiveCache = JSON.stringify(archiveCache);
localStorage.holotoolsArchiveLatestUpdated = new Date().getTime();
updateArchive(archiveCache);
}
} catch (ex) {
console.log(ex, context);
}
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) {
var diff = new Date(end).getTime() - new Date(start).getTime();
var dm = Math.floor(diff / 1000 / 60 % 60);
var 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);
var h = ("0" + start.getHours()).slice(-2);
var m = ("0" + start.getMinutes()).slice(-2);
if (start.getDate() != new Date().getDate()) {
return `${start.getDate()}-${h}:${m}`;
} else {
return `${h}:${m}`;
}
}
})();