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