// ==UserScript==
// @name AbemaTV, ニコニコ風コメントスクロール
// @description AbemaTVのコメントをスクリーン上に表示、またはニコニコ動画風にスクロールするスクリプト
// @namespace https://greasyfork.org/users/1242
// @include https://abema.tv/*
// @version 0.4.1
// @run-at document-end
// @grant GM_xmlhttpRequest
// @grant exportFunction
// @icon https://greasyfork.org/system/screenshots/screenshots/000/004/030/original/amebatv.png?1462005933
// ==/UserScript==
(function () {
"use strict";
var window = typeof unsafeWindow == "undefined" ? window : unsafeWindow,
document = window.document,
GM_TAG = "[AbemaTV, ニコニコ風コメントスクロール]",
DEBUG = false;
var settings = new Settings(),
loader = new CommentLoader(),
floater = new CommentFloat(),
scroller = new CommentScroll(),
poster = new CommentPost(),
schedule = new Schedule(),
zapping = new Zapping(),
ng = new CommentNG();
var FLOAT_LIMIT_MIN = 30,
FLOAT_LIMIT_MAX = 500,
SCROLL_FONTSIZE_MIN = 10,
SCROLL_FONTSIZE_MAX = 100,
SCROLL_SPEED_MIN = 1000,
SCROLL_SPPED_MAX = 10000,
SCROLL_LINE_MIN = 3,
SCROLL_LINE_MAX = 30;
/**************************************
* 汎用メソッド
**************************************/
// 関数をunsafeWindowにエクスポートする
// Greasemonkey2.0から要素にイベントリスナ登録する際は必要
function exportGMFunc(fn, name) {
var fnName = name || fn.name;
if (exportFunction) {
exportFunction(fn, window, { defineAs: fnName });
} else {
window[fnName] = fn;
}
return window[fnName];
}
// ログ出力
function log(output) {
if (DEBUG) {
window.console && console.log(GM_TAG, output);
}
}
// イベントリスナ登録
function bind(events, callback, useCapture) {
events = events.split(" ");
for (var i = 0; i < events.length; i++) {
if (this.addEventListener) {
this.addEventListener(events[i], callback, useCapture);
} else if (this.attachEvent) {
this.attachEvent("on" + events[i], callback, useCapture);
}
}
}
window.Element.prototype.bind = bind;
window.Document.prototype.bind = bind;
window.Window.prototype.bind = bind;
// クラスがあるか否かを取得する
window.Element.prototype.hasClass = function (className) {
var tagClass = " " + this.className + " ";
return tagClass.indexOf(" " + className + " ") >= 0;
}
// クラスを追加する
window.Element.prototype.addClass = function (className) {
this.className = (this.className + " " + className).trim();
}
// クラスを削除する
window.Element.prototype.removeClass = function (className) {
var tagClass = " " + this.className + " ";
this.className = tagClass.replace(new RegExp(" " + className + " ", "gm"), "").trim();
}
// HTMLエスケープ
var escapeHtml = (function () {
var maps = {
'&': '&',
"'": ''',
'`': '`',
'"': '"',
'<': '<',
'>': '>'
};
var reg = '[';
for (var k in maps) {
reg += maps[k];
}
reg += ']';
reg = new RegExp(reg, 'gm');
return function (str) {
if (str === null) return "";
return str.replace(reg, function (match) {
return maps.hasOwnProperty(match) ? maps[match] : match;
});
};
}());
// 単数の要素を取得する
function $(selector, element) {
if (element) {
return element.querySelector(selector);
} else {
return document.querySelector(selector);
}
};
// 複数の要素を取得する
function $$(selector, element) {
if (element) {
return element.querySelectorAll(selector);
} else {
return document.querySelectorAll(selector);
}
};
// 要素を作成する
// @html html
// @container 指定した要素に追加
// @returns 作成した要素の配列
function $e(html, container) {
var holder = document.createElement("div");
holder.innerHTML = html;
var children = [];
for (var i = 0; i < holder.childNodes.length; i++) {
children.push(holder.childNodes[i]);
if (container) {
container.appendChild(holder.childNodes[i]);
}
}
return children;
}
// 文字列マッピング
function stringMap(target, maps) {
var output = target;
for (var k in maps) {
output = output.replace("{" + k + "}", maps[k]);
}
return output;
}
// CSSを追加
function addStyle(style) {
var tag = document.createElement("style");
tag.innerHTML = style;
document.getElementsByTagName("head")[0].appendChild(tag);
return tag;
}
// 乱数を生成
function getRandomInteger(min, max) {
return Math.ceil(Math.random() * (max - min) + min);
}
/**************************************
* スクリプトの実装
**************************************/
// localStorage に保存した設定のラッパー
function Settings() {
var self = this;
var Default = function () { }
Default.prototype = {
float: true,
floatreverse: false,
floatlimit: 50,
scroll: true,
scrollfontsize: 28,
scrollspeed: 5000,
scrollmaxline: 8,
twitter: false,
wheelzapping: false,
ng: false,
}
var data;
var handlers = {};
// 登録したリスナへ設定変更された時に通知する
this.addListener = function (key, callback) {
if (!handlers[key]) {
handlers[key] = [callback];
} else {
handlers[key].push(callback);
}
}
// 設定を保存する
this.save = function () {
if (localStorage && localStorage.setItem) {
localStorage.setItem(GM_TAG, JSON.stringify(data));
}
}
// 設定を取得する
this.get = function (key) {
try {
if (!data) {
data = new Default();
if (localStorage && localStorage.getItem) {
var d = JSON.parse(localStorage.getItem(GM_TAG));
for (var k in d) {
if (typeof d[k] != "undefined") {
data[k] = d[k];
}
}
}
}
return data[key];
} catch (ex) {
}
}
// 設定を変更する
this.set = function (key, value) {
try {
data[key] = value;
self.save();
var h = handlers[key];
if (h) {
for (var i = 0; i < h.length; i++) {
try {
h[i] && h[i](key, value);
} catch (ex) { }
}
}
} catch (ex) {
log(ex);
return false;
}
return true;
}
// コマンドで設定を変更する
// @returns 更新に成功したか否か
this.setByCommand = function (command) {
var args = command.split(" ");
var key = args[0].substr(1).toLowerCase();
switch (key) {
case "scroll":
case "float":
case "floatreverse":
case "twitter":
case "wheelzapping":
case "ng":
if (args[1] == "on") {
return settings.set(key, true);
} else if (args[1] == "off") {
return settings.set(key, false);
} else {
return settings.set(key, !settings.get(key));
}
break;
case "floatlimit":
var limit = parseInt(args[1]);
if (FLOAT_LIMIT_MIN <= limit && limit <= FLOAT_LIMIT_MAX) {
return settings.set(key, limit);
} else {
throw new RangeError(stringMap("{min}から{max}までの間でなければなりません", {
min: FLOAT_LIMIT_MIN,
max: FLOAT_LIMIT_MAX
}));
}
break;
case "scrollspeed":
var speed = parseInt(args[1]);
if (SCROLL_SPEED_MIN <= speed && speed <= SCROLL_SPPED_MAX) {
return settings.set(key, speed);
} else {
throw new RangeError(stringMap("{min}から{max}までの間でなければなりません", {
min: SCROLL_SPEED_MIN,
max: SCROLL_SPPED_MAX
}));
}
break;
case "scrollfontsize":
var size = parseInt(args[1]);
if (SCROLL_FONTSIZE_MIN <= size && size <= SCROLL_FONTSIZE_MAX) {
return settings.set(key, size);
} else {
throw new RangeError(stringMap("{min}から{max}までの間でなければなりません", {
min: SCROLL_FONTSIZE_MIN,
max: SCROLL_FONTSIZE_MAX
}));
}
break;
case "scrollmaxline":
var line = parseInt(args[1]);
if (SCROLL_LINE_MIN <= line && line <= SCROLL_LINE_MAX) {
return settings.set(key, line);
} else {
throw new RangeError(stringMap("{min}から{max}までの間でなければなりません", {
min: SCROLL_LINE_MIN,
max: SCROLL_LINE_MAX
}));
}
break;
case "font":
return settings.set(key, escapeHtml(args.slice(1).join(" ").replace(/:;\n\r/g, " ")));
break;
default:
throw new Error(stringMap("{key} は無効なコマンドです", { key: key }));
break;
}
}
// 初期化する
this.init = function () {
log("Settings.init()");
}
log("Settings()");
}
// コメント読み込みを実装
function CommentLoader() {
var self = this;
var counter;
var lastPostedTime = 0;
var lastCount = 0;
var lastLoadedTime = 0;
var observeTimer = 0;
var KMGP_REGEXP = /[kmgp]/;
this.COMMENT_API = "https://api.abema.io/v1/slots/{slotId}/comments?limit={limit}";
this.interval = 500;
this.slotId = null;
this.lastSlotId = null;
this.channel = null;
this.lastChannel = null;
this.enable = false;
// 新着コメントの監視を開始する
this.start = function () {
self.enable = true;
observeTimer = setInterval(self._observe, self.interval);
}
// 新着コメントの監視を終了する
this.stop = function () {
self.enable = false;
clearInterval(observeTimer);
}
// コメント取得の前にOPSTIONSメソッドでリクエストを送信する
this._optionCommentsApi = function (slotId, callback) {
GM_xmlhttpRequest({
url: stringMap(self.COMMENT_API, {
slotId: slotId,
limit: 20
}),
method: "OPTIONS",
headers: {
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization,content-type",
},
onload: callback,
});
}
// コメント取得のリクエストを送信する
this._getCommentsApi = function (slotId, callback) {
GM_xmlhttpRequest({
url: stringMap(self.COMMENT_API, {
slotId: slotId,
limit: 20
}),
method: "GET",
headers: {
"Authorization": "bearer " + getAbmToken(),
},
onload: callback,
})
}
// コメントを取得する
this._loadComment = function (channel, slotId) {
lastLoadedTime = new Date().getTime();
// コメント読み込み
self._getCommentsApi(slotId, function (e) {
// コメントを読み込む前にOPTIONSメソッドで一回叩く必要がある
// 404が返ってきたら、初期化読み込み後に再度コメント読み込み
if (e.status == 404) {
self._optionCommentsApi(slotId, function () {
self._getCommentsApi(slotId, function (e) {
self._onCommentLoaded(e, channel, slotId);
});
});
} else {
self._onCommentLoaded(e, channel, slotId);
}
});
}
// コメントを取得した時に呼び出される
this._onCommentLoaded = function (e, channel, slotId) {
// log(e);
try {
if (!e || !e.responseText) return;
var data = JSON.parse(e.responseText);
if (!data || !data.comments) return;
// 新しいコメントのインデックスが小さいので後ろから探索する
for (var i = data.comments.length - 1; 0 <= i; i--) {
var c = data.comments[i];
if (lastPostedTime < c.createdAtMs) {
lastPostedTime = c.createdAtMs;
// log(c.message);
c.channel = channel;
c.slotId = slotId;
self.onLoaded(c);
}
}
} catch (ex) {
log(ex);
}
}
// 新着コメントを監視する
this._observe = function () {
if (!self.enable) return;
// log("_observe()");
try {
var channel = self.channel;
var slotId = self.slotId || self.lastSlotId;
// log("channel: " + channel + ", slotId: " + slotId);
// 放送ページでない
if (channel == null || channel == "") return;
if (!slotId) return;
// チャンネル変更後CM
if (channel != self.lastChannel && slotId == null) return;
self.lastChannel = channel;
self.lastSlotId = slotId;
// コメント数
if (!counter) self._getCountElement();
var count = counter && parseInt(counter.innerHTML);
var isThousandOver = counter && counter.innerHTML.match(KMGP_REGEXP);
// コメント数要素を取得できなかったら、コメントを取得しない
if (typeof count == "undefined") {
log("count is undefined");
return;
}
// CM中
if (count == NaN || count == 0) {
// 最後の読み込みから3秒以下なら、取得しない
if (new Date().getTime() - lastLoadedTime <= 3010) {
return;
}
}
// TODO: 現在は1000件以上のコメント数の取得が機能していない
// コメント数が増えてなかったら、コメントを取得しない
else if (lastCount >= count) {
// コメントが1000件以上で最後の読み込みが1.2秒以下なら取得しない
if (isThousandOver && new Date().getTime() - lastLoadedTime <= 1210) {
return;
}
} else {
lastCount = count;
}
self._loadComment(channel, slotId);
} catch (e) {
log(e);
}
}
// コメント数要素を取得する
this._getCountElement = function () {
return counter = $('span[data-reactid=".0.$main.0.1.1.0.2.1.1"]');
}
// 初期化する
this.init = function () {
log("CommentLoader.init()")
}
this.onLoaded = null;
log("CommentLoader()");
}
// コメントフロート表示を実装
function CommentFloat() {
var self = this;
var lastSlotId = null;
this.enable = true;
this.isReverse = false;
this.container = null;
this.limit = 50;
// コメントを追加する
this.push = function (comment) {
if (!self.enable) return;
var isSlotIdChanged = lastSlotId != null && lastSlotId != comment.slotId;
lastSlotId = comment.slotId;
var container = self.container;
// 除去
if (self.limit <= container.childNodes.length) {
if (self.isReverse) {
// 昇順
container.removeChild(container.firstChild);
} else {
// 降順
container.removeChild(container.lastChild);
}
}
// 追加
var commentElement = $e(stringMap('<span class="comment{isFirst}" channel="{channel}" id="{id}">{message}</span>', {
message: escapeHtml(comment.message),
channel: escapeHtml(comment.channel),
id: escapeHtml(comment.id),
isFirst: isSlotIdChanged ? " isFirst" : ""
}))[0];
commentElement.commentObjct = comment;
if (self.isReverse) {
// 昇順
container.appendChild(commentElement);
if (container.scrollHeight - container.clientHeight - 50 <= container.scrollTop) {
container.scrollTo(0, container.scrollHeight);
}
} else {
// 降順
container.insertBefore(commentElement, container.firstChild);
}
}
// コメントを全て除去する
this.clear = function () {
while (self.container.firstChild) {
self.container.removeChild(self.container.firstChild);
}
}
// コメント一覧を上下反転する
this.reverse = function () {
var container = self.container;
var comments = Array.prototype.slice.call(container.childNodes).reverse();
for (var i = 0; i < comments.length; i++) {
container.appendChild(comments[i]);
}
}
// 初期化する
var initilized = false;
this.init = function (options) {
log("CommentFloat.init()")
if (!initilized) {
initilized = true;
addStyle("\
#floatComments { position: fixed; top: 44px; right: -20px; bottom: 115px; width: 340px; padding: 5px 50px 5px 5px;\
color: #fff; background: rgba(0,0,0,0.25); z-index: 8; overflow: auto; }\
#floatComments .comment { display: block; padding: 3px; overflow: hidden;\
border: 1px solid rgba(255,255,255,0.05); border-width: 0 0 1px 0; word-break: break-all; }\
#floatComments.reverse { border-width: 1px 0 0 0; }\
#floatComments .comment:last-child { border: 0; }\
#floatComments .comment.isFirst { border-color: rgba(255,255,255,0.5); }\
#floatComments.disable { visibility: hidden; }\
");
self.container = $e('<div id="floatComments"></div>', document.body)[0];
if (!options.enable) {
self.container.addClass("disable");
}
self.enable = options.enable;
self.isReverse = options.isReverse;
self.limit = options.limit;
} else {
self.container.style.display = "block";
}
}
this.hide = function () {
if (self.container) {
self.container.style.display = "none";
}
}
log("CommentFloat()");
}
// コメントスクロール表示を実装
function CommentScroll() {
var self = this;
var comments = new Array(); // 最大20行
for (var i = 0; i < 20; i++) {
comments.push({ count: -1 });
}
this.container = null;
this.maxLine = 8;
this.speed = 5000;
this.fontSize = 28;
// コメントをスクロールする
this.scroll = function (comment) {
if (!self.enable) return;
// 古いコメントは流さない
// 本当は10秒くらいがいいが取りこぼしが出るので大きめに設定
if (new Date().getTime() - comment.createdAtMs > 30000) {
// log("old comment donot scroll");
// log(comment);
return;
}
// コメントのハッシュタグを除去する
comment.message = comment.message.replace(/[##♯].*$/g, "");
// 要素の作成
var c = $e(stringMap('<span class="comment" uid="{uid}">{message}</span>', {
message: escapeHtml(comment.message),
uid: escapeHtml(comment.id),
}), self.container)[0];
c.style.fontSize = self.fontSize + "px";
c.style.lineHeight = self.fontSize * 1.1 + "px";
c.addClass("scroll");
var duration = self.speed; // スクロールに要する時間
var maxLine = self.maxLine; // コメントの最大行
var timeDelay = 300; // 同時に取得したコメントが縦一列にならないようにするディレイ
var marginWidth = 50; // 最後までスクロール仕切らないので余白を追加
var bodyWidth = document.body.clientWidth; // BODYの横幅
var commentWidth = c.clientWidth; // コメントの横幅
var totalWidth = bodyWidth + commentWidth; // スクロールする総距離
var pxpms = totalWidth / duration; // 1ms間にスクロールする距離
var commentScrollDuration = commentWidth / pxpms; // コメントのスクロールに要する時間
var bodyScrollDuration = bodyWidth / pxpms; // BODYのスクロールに要する時間
var marginScrollDuration = marginWidth / pxpms; // マージンのスクロールに要する時間
var now = new Date().getTime(); // 現在時間
var renderDelay = 0; // スクロール開始までのディレイ
var renderLine = 0; // スクロールする行
var renderOverride = false;
for (var i = 0; i < maxLine; i++) {
// 初回
if (comments[i].count < 0) {
comments[i].count = 0;
renderLine = i;
renderDelay = 0;
break;
}
// 後のコメントの方が長い(速い)
else if (comments[i].commentWidth < commentWidth) {
// 画面左端でコメントが被らない
if (comments[i].scrollEndTime < now + bodyScrollDuration - marginScrollDuration) {
renderLine = i;
renderDelay = i * timeDelay;
break;
}
}
// 後ろのコメントの方が短い(遅い)、画面右端でコメントが被らない
else if (comments[i].commentFullViewTime < now - marginScrollDuration) {
renderLine = i;
renderDelay = i * timeDelay;
break;
}
// すべての行でコメントが被る
else if (maxLine - 1 == i) {
renderLine = getRandomInteger(0, maxLine - 2);
renderDelay = 0;
renderOverride = true;
break;
}
}
if (!renderOverride) {
comments[renderLine].count++;
comments[renderLine].commentWidth = commentWidth;
comments[renderLine].scrollEndTime = now + renderDelay + duration;
comments[renderLine].commentFullViewTime = now + renderDelay + commentScrollDuration;
}
setTimeout(function () {
if (renderOverride) {
c.style.opacity = 0.8;
}
c.style.transition = stringMap("left {0}ms linear", {
0: duration + marginScrollDuration
});
c.style.top = (renderLine * self.fontSize * 1.1 + 45 + (renderOverride ? 22 : 0)) + "px";
c.style.left = "-" + (commentWidth + marginWidth) + "px";
setTimeout(function () {
if (!renderOverride) {
comments[renderLine].count--;
}
self.container.removeChild(c);
}, duration);
}, renderDelay);
}
// 初期化する
var initilized = false;
this.init = function (options) {
log("CommentScroll.init()")
if (!initilized) {
initilized = true;
addStyle("\
#scrollComments { position: absolute; top: 0; right: 0; left: 0; bottom: 0; overflow: hidden;\
font-size: 28px; font-weight: bold; color: #fff; }\
#scrollComments .comment { position: fixed; opacity: 0; left: 0; top: 0; z-index: 8;\
text-shadow: #000 1px 1px 1px; white-space: nowrap; }\
#scrollComments .comment.scroll { opacity: 1; left: 100%; }\
#scrollComments.disable { visibility: hidden; }\
");
self.container = $e('<div id="scrollComments"></div>', document.body)[0];
if (!options.enable) {
self.container.addClass("disable");
}
self.enable = options.enable;
self.speed = options.speed;
self.fontSize = options.fontSize;
self.maxLine = options.maxLine;
} else {
self.container.style.display = "block";
}
}
this.hide = function () {
if (self.container) {
self.container.style.display = "none";
}
}
log("CommentScroll()");
}
// トーストを実装する
function Toast() {
var self = this;
var toast;
var showTimer;
// トーストを開く(クラスのみ付加、実装はCSS)
this.show = function (message, timeout) {
toast.innerHTML = escapeHtml(message);
toast.className = "toast shown";
clearTimeout(showTimer);
showTimer = setTimeout(function () {
self.close();
}, typeof timeout == undefined ? 5000 : timeout);
}
// トーストを閉じる(クラスのみ付加、実装はCSS)
this.close = function () {
clearTimeout(showTimer);
toast.className = "toast";
}
// 初期化する
this.init = function (container) {
toast = $e('<span class="toast"></span>', container)[0];
}
}
// コメント投稿を実装
function CommentPost() {
var self = this;
var toast = new Toast();
var form, commentField;
var deactiveTimer = 0;
this.container = null;
this.COMMENT_LIMIT = 50;
this.POST_API = "https://api.abema.io/v1/slots/{slotId}/comments";
this.slotId = null;
this.isShare = false;
// コメント投稿する
this.post = function (message) {
log(message);
message = message.trim();
if (message.indexOf("/") == 0) {
try {
if (message == "/ngconfig") {
ng.showConfig();
} else if (settings.setByCommand(message)) {
toast.show("設定しました", 3000);
} else {
toast.show("設定に失敗しました", 5000);
}
} catch (ex) {
toast.show(ex.message, 5000);
}
} else if (message.length > self.COMMENT_LIMIT) {
toast.show(stringMap("コメントが文字数制限{limit}文字を超えています", {
limit: self.COMMENT_LIMIT
}), 5000);
} else {
self.slotId = self.slotId || lastSlotId;
self._postComment(message, function (e) {
log(e);
});
}
}
// コメントを投稿する
this._postComment = function (message, callback) {
var shareData = null;
if (self.isShare) {
shareData = {
twitter: getTwitterObject(),
elapsed: new Date().getTime() % 10000,
};
if (!shareData.twitter) {
shareData = null;
}
log(shareData);
}
GM_xmlhttpRequest({
url: stringMap(self.POST_API, {
slotId: self.slotId,
}),
data: JSON.stringify({
message: message,
share: shareData,
}),
method: "POST",
headers: {
"Authorization": "bearer " + getAbmToken(),
"Content-Type": "application/json",
},
onload: callback,
onerror: callback,
})
}
// フォームがSubmitされたときに呼び出される
this.onSubmit = function (e) {
log("onSubmit()");
self.post(e.target.message.value);
e.target.message.value = "";
return false;
}
// アクティブ化する
this.onActive = function () {
form.className = "active";
clearTimeout(deactiveTimer);
deactiveTimer = setTimeout(self.onDeactive, 5000);
}
// 非アクティブ化する
this.onDeactive = function () {
if (commentField.value.length == 0) {
form.className = "";
}
}
// フォームがKeyDownされたときに呼び出される
this.onFormKeydown = function (e) {
// 上下キーを無効
if (e.keyCode == 38 || e.keyCode == 40) {
e.stopImmediatePropagation();
e.preventDefault();
e.stopPropagation();
}
}
// コメントが入力されたとき呼び出される
this.onInput = function () {
var remaing = self.COMMENT_LIMIT - commentField.value.length;
if (remaing < 0) {
commentField.value = commentField.value.substr(0, self.COMMENT_LIMIT);
toast.show(stringMap("コメント文字数制限は50文字です", {
limit: self.COMMENT_LIMIT
}), 3000);
} else if (remaing < 20) {
toast.show(stringMap("残り{0}文字入力可能です", { 0: remaing }), 3000);
}
}
// 初期化する
var initilized = false;
this.init = function (options) {
log("CommentPost.init()")
if (!initilized) {
initilized = true;
self.isShare = options.isShare;
addStyle("\
#CommentPost {\
position: absolute; bottom: 78px; right: 120px;\
z-index: 9; transition: opacity 0.1s ease-in-out; opacity: 0.01;\
}\
#CommentPost.active { opacity: 1; }\
#CommentPost input {\
min-width: 300px; max-width: 500px; height: 25px; line-height: 25px; padding: 1px 25px 1px 5px;\
border: 2px solid #fff; background: rgba(255,255,255,0.2); color: #fff; font-size: 15px;\
}\
#CommentPost input:focus, #CommentPost input:active { background: rgba(0,0,0,0.4); }\
#CommentPost .toast { position: absolute; left: 0; right: 0; bottom: 100%;\
height: 24px; border-radius: 12px; margin-bottom: 5px; padding: 2px 12px;\
background: rgba(22,22,22,0.95); color: #fff; opacity: 0; transition: opacity 0.3s ease-in-out; }\
#CommentPost .toast.shown { opacity: 1; }\
");
self.container = form = $e('<form id="CommentPost" onSubmit="return false;">\
<input type="text" name="message" autocomplete="off" placeholder="コメント&コマンド入力" />\
<span class="count"></span>\
</form>', document.body)[0];
commentField = $("input", form);
self.onSubmit = exportGMFunc(self.onSubmit, "GM_ANLCS_CommentPost_onSubmit");
self.onFormKeydown = exportGMFunc(self.onFormKeydown, "GM_ANLCS_CommentPost_onFormKeydown");
self.onActive = exportGMFunc(self.onActive, "GM_ANLCS_CommentPost_onActive");
self.onDeactive = exportGMFunc(self.onDeactive, "GM_ANLCS_CommentPost_onDeactive");
self.onInput = exportGMFunc(self.onInput, "GM_ANLCS_CommentPost_onInput");
// コメント投稿
form.bind("submit", self.onSubmit);
// アクティブ化
document.body.bind("mousemove", self.onActive);
form.bind("keydown focus mousemove", self.onActive);
// キーダウン
form.bind("keydown", self.onFormKeydown);
// コメント入力
commentField.bind("keyup change", self.onInput);
// トースト初期化
toast.init(form);
} else {
form.style.display = "block";
}
var sideForm = $('[class^="TVContainer__right-comment-area"] form');
$("textarea", sideForm).setAttribute("placeholder", "コメントを入力、設定コマンドは右下コメント欄へ");
}
this.hide = function () {
if (form) {
form.style.display = "none";
}
}
log("CommentPost()");
}
// スケジュール表示を実装
function Schedule() {
var self = this;
var closeTimer;
var DAY_TIMES = 24 * 60 * 60 * 1000;
var httpLoading = false;
var lastViewChannel = null;
this.container = null;
this.LOGO_URL = "https://hayabusa.io/abema/channels/logo/{channel}.w100.png";
this.MEDIA_API = "https://api.abema.io/v1/media?dateFrom={from}&dateTo={to}";
this.sendOptions = false;
this.schedule = {}
// コメント取得の前にOPSTIONSメソッドでリクエストを送信する
this._options = function (from, to, callback) {
GM_xmlhttpRequest({
url: stringMap(self.MEDIA_API, {
from: from,
to: to
}),
method: "OPTIONS",
headers: {
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization,content-type",
},
onload: callback,
});
}
// コメント取得のリクエストを送信する
this._get = function (from, to, callback) {
GM_xmlhttpRequest({
url: stringMap(self.MEDIA_API, {
from: from,
to: to,
}),
method: "GET",
headers: {
"Authorization": "bearer " + getAbmToken(),
},
onload: callback,
});
}
// Dateオブジェクトから年月日の文字列を返す
this.getDateKey = function (dateObj) {
try {
var dateKey = "";
dateKey += dateObj.getFullYear();
dateKey += ("0" + (dateObj.getMonth() + 1)).slice(-2);
dateKey += ("0" + dateObj.getDate()).slice(-2);
return dateKey;
} catch (ex) { log(ex) }
}
// 指定された日のチャンネルの番組一覧を生成する
// @returns 生成したか否か
this._create = function (channelId, date) {
var dateKey = self.getDateKey(date);
var schedule = self.schedule[dateKey];
if (!schedule) return false;
// スロットを捜索
var slots;
for (var i = 0 ; i < schedule.channels.length; i++) {
if (schedule.channels[i].id == channelId) {
slots = schedule.channelSchedules[i].slots;
break;
}
}
if (!slots) return false;
// 日付ヘッダーを挿入する
$e(stringMap('<div class="header">{month}月{date}日</div>', {
month: date.getMonth() + 1,
date: date.getDate(),
}), self.container);
// 番組一覧を生成する
for (var j = 0; j < slots.length; j++) {
var now = new Date();
var startAt = new Date(slots[j].startAt * 1000);
var endAt = new Date(slots[j].endAt * 1000);
$e(stringMap('<div class="item {onair}"><span class="date">{startAt}-{endAt}</span><span class="title">{title}</span></div>', {
title: slots[j].title,
startAt: stringMap("{HH}:{MM}", {
HH: ("0" + startAt.getHours()).slice(-2),
MM: ("0" + startAt.getMinutes()).slice(-2)
}),
endAt: stringMap("{HH}:{MM}", {
HH: ("0" + endAt.getHours()).slice(-2),
MM: ("0" + endAt.getMinutes()).slice(-2)
}),
onair: startAt <= now && now < endAt ? "onair" : ""
}), self.container);
}
return true;
}
// チャンネルのプログラムリストを開く
this.show = function (channelId, date, isAppend) {
log("Schedule.show(): " + channelId + ", " + date);
// スクロール位置を保存
var scrollTop = self.container.scrollTop;
if (isAppend) {
// 次の日を読み込むボタンを除去する
var nextElement = $(".next", self.container);
if (nextElement) {
self.container.removeChild(nextElement);
}
} else {
// 要素の中身を一掃する
self.container.innerHTML = "";
}
var hasSlots = false;
// 取得済みの番組一覧を生成する
while (self._create(channelId, date)) {
// 次の日
date = new Date(date.getTime() + DAY_TIMES);
hasSlots = true;
}
if (!hasSlots) return;
// 次の日の番組があれば、読み込むボタンを生成する
var dateKey = self.getDateKey(date);
// 有効な番組日時リスト
var availableDates;
for (var key in self.schedule) {
availableDates = self.schedule[key].availableDates;
if (availableDates) break;
}
for (var i = 0 ; i < availableDates.length; i++) {
if (availableDates[i] == dateKey) {
// 番組表があればボタンを生成する
var next = $e(stringMap('<div class="next" next="{next}" channel="{channel}">次の日を表示</div>', {
next: date.getTime(),
channel: channelId,
}), self.container)[0];
next.bind("click", self.onNext);
break;
}
}
// スクロール位置を復元
self.container.scrollTop = scrollTop;
// チャンネルロゴの表示
self.container.style.backgroundImage = stringMap("url('" + self.LOGO_URL + "')", {
channel: channelId
});
self.container.className = "shown";
// サイドバーの開閉を監視
clearInterval(closeTimer);
closeTimer = setInterval(function () {
var list = $('[class*="right-v-channel-list"]')
if (list.parentNode.className.indexOf("shown") < 0) {
self.onClose();
}
}, 250);
}
// 次の日を表示する
this.onNext = function (e) {
e.stopImmediatePropagation();
e.preventDefault();
e.stopPropagation();
if (httpLoading) return;
var next = $(".next", self.container);
next.className = "next loading";
var channelId = next.getAttribute("channel");
var nextTimes = parseInt(next.getAttribute("next"));
var nextDate = new Date(nextTimes);
var nextDateKey = self.getDateKey(nextDate);
var schedule = self.schedule[nextDateKey];
if (schedule) {
self.show(channelId, nextDate, true);
} else {
httpLoading = true;
self._get(nextDateKey, nextDateKey, function (e) {
log(e)
httpLoading = false;
if (e.status == 200) {
try {
self.schedule[nextDateKey] = JSON.parse(e.responseText);
self.show(channelId, nextDate, true);
} catch (ex) {
log(ex);
}
}
});
}
}
// 番組
this.onHoverProgram = function (e) {
var logo = $("img[class*='logo']", e.target);
if (logo) {
var channelId = logo.getAttribute("alt");
var today = new Date();
var todayKey = self.getDateKey(today);
if (self.schedule[todayKey]) {
self.show(channelId, today);
}
lastViewChannel = channelId;
}
}
// 番組表を閉じる
this.onClose = function () {
clearInterval(closeTimer);
self.container.className = "";
}
// 放送中番組一覧ボタンをクリックされたとき
// 今日の番組表を取得する
this.onClick = function () {
var today = new Date();
var todayKey = self.getDateKey(today);
if (!self.schedule[todayKey]) {
httpLoading = true;
self._options(todayKey, todayKey, function (e) {
// log(e)
self._get(todayKey, todayKey, function (e) {
// log(e)
if (e.status == 200) {
try {
self.schedule[todayKey] = JSON.parse(e.responseText);
log(self.schedule);
if (lastViewChannel) {
self.show(lastViewChannel, today);
}
} catch (ex) {
log(ex);
}
}
httpLoading = false;
});
});
}
}
var initilized = false;
// 初期化する
this.init = function () {
log("Schedule.init()");
if (!initilized) {
initilized = true;
addStyle("\
#gmSchedule { display: none; position: fixed; top: 0; right: -852px; bottom: 0; width: 852px; z-index: 9; overflow-y: scroll;\
background: #101010 no-repeat 280px 20px; opacity: 0; transition: opacity 0.2s ease-in, right 1s ease-in; color: #fff; }\
#gmSchedule.shown { display: block; opacity: 1; right: 0; }\
#gmSchedule.shown:after { display: block; content: ''; position: absolute; top: 0; left: 400px; bottom: 0; right: 0;\
background: #000; box-shadow: 0 0 8px #000; }\
#gmSchedule .item, #gmSchedule .next, #gmSchedule .header { padding: 1px 16px; width: 400px; }\
#gmSchedule .item:first-child, #gmSchedule .header { margin-top: 15px; }\
#gmSchedule .item:last-child, #gmSchedule .next { margin-bottom: 15px; }\
#gmSchedule .item.onair { background: #606060; }\
#gmSchedule .item .date { display: inline-block; color: #aaa; font-size: 11px; width: 70px; }\
#gmSchedule .item .title { font-size: 13px; }\
#gmSchedule .next { cursor: pointer; text-align: center; }\
#gmSchedule .header { text-align: center; }");
self.onHoverProgram = exportGMFunc(self.onHoverProgram, "GM_ANLCS_Schedule_onHoverProgram");
self.onClick = exportGMFunc(self.onClick, "GM_ANLCS_Schedule_onClick");
self.onClose = exportGMFunc(self.onClose, "GM_ANLCS_Schedule_onClose");
self.onNext = exportGMFunc(self.onNext, "GM_ANLCS_Schedule_onClose");
self.container = $e('<div id="gmSchedule"></div>', document.body)[0];
self.container.bind("click", self.onClose);
}
var openerButton = $$('[class^="TVContainer__side"] [class*="box"] [class*="button"]')[1];
openerButton && openerButton.bind("click", self.onClick);
var programList =$('[class*="right-slide-base"]');
programList && programList.bind("mouseover", self.onHoverProgram);
}
log("Schedule()")
}
// マウスホイールでチャンネル変更を有効化/無効化
function Zapping() {
var self = this;
this.enable = false;
var initilized = false;
this.init = function (options) {
var overlap = $("[class*='style__overlap']");
if (overlap) {
overlap.bind("wheel mousewheel DOMMouseScroll", exportGMFunc(function (e) {
if (self.enable) {
e.stopImmediatePropagation();
e.preventDefault();
e.stopPropagation();
}
}, "GM_ANLCS_overlap_onMousewheel"), true);
}
if (!initilized) {
initilized = true;
self.enable = options.enable;
}
}
}
// コメントNG機能を実装
function CommentNG() {
var self = this;
var NG_FILTER_ITEM = '<div class="item"><input type="text" class="ng" /><button class="button remove">削除</button></div>';
this.filters = [];
this.enable = false;
this.container = null;
this.setFilters = function (value) {
var filters = [];
if (Array.isArray(value)) {
for (var i = 0; i < value.length; i++) {
if (self.isRegExp(value[i])) {
filters.push(new RegExp(value[i].slice(1,-1), "gm"));
} else if (value[i].length > 0) {
filters.push(value[i]);
}
}
}
self.filters = filters;
}
// NGリストとマッチングする
this.match = function (target) {
log(target)
log(self.filters)
for (var i = 0 ; i < self.filters.length; i++) {
if (self.filters[i].exec) {
if (target.match(self.filters[i])) {
return true;
}
} else {
if (target.indexOf(self.filters[i]) >= 0) {
return true;
}
}
}
return false;
}
// 設定画面を表示する
this.showConfig = function () {
var itemContainer = $(".list", self.container);
itemContainer.innerHTML = "";
var filters = settings.get("ngfilter");
if (Array.isArray(filters)) {
for (var i = 0 ; i < filters.length; i++) {
var item = $e(NG_FILTER_ITEM, itemContainer)[0];
$(".ng", item).value = filters[i];
}
}
self.container.addClass("shown");
}
this.isRegExp = function (text) {
return text.length >= 2 && text.substr(0, 1) == "/" && text.slice(-1) == "/";
}
// 設定を適応する
this.onApplyConfig = function (ev) {
var filters = [];
var elements = $$(".ng", self.container);
for (var i = 0; i < elements.length; i++) {
var text = elements[i].value;
if (self.isRegExp(text) || text.length >= 1) {
filters.push(text);
}
}
settings.set("ngfilter", filters);
self.setFilters(filters);
self.container.removeClass("shown");
}
// 設定を閉じる
this.onCancelConfig = function () {
self.container.removeClass("shown");
}
// NGフィルタを除去する
this.onRemoveItem = function (ev) {
if (window.Element.prototype.hasClass.call(ev.target, "remove")) {
$(".list", self.container).removeChild(ev.target.parentNode);
return false;
}
}
// NGフィルタを追加する
this.onAddItem = function () {
$e(NG_FILTER_ITEM, $(".list", self.container));
}
// NGフィルタのテストをする
this.onTest = function () {
var text = $(".test .text", self.container).value;
var elements = $$(".ng", self.container);
for (var i = 0; i < elements.length; i++) {
elements[i].removeClass("match");
var text = elements[i].value;
if (self.isRegExp(text)) {
if (text.match(new RegExp(text.slice(1, -1), "gm"))) {
elements[i].addClass("match");
}
} else if (text.length >= 1) {
if (text.indexOf(text) >= 0) {
elements[i].addClass("match");
}
}
}
}
var initilized = false;
// 初期化
this.init = function (options) {
if (!initilized) {
initilized = true;
addStyle('\
#NGConfig { display:none; position: fixed; bottom: 73px; right: 115px; z-index: 9;\
background: #101010; min-width: 310px; border-radius: 5px; padding: 12px; }\
#NGConfig.shown { display: block; }\
#NGConfig .list { max-height: 500px; overflow-y: auto; }\
#NGConfig .list .item { margin: 2px 0; }\
#NGConfig .list .item .ng { width: 200px; border: 0; }\
#NGConfig .list .item .ng.match { background: yellow; }\
#NGConfig .test { margin: 5px; }\
#NGConfig .test .text { border: 0; }\
#NGConfig .command { text-align: right; }\
#NGConfig .button { color: #fff; margin-left: 10px; }\
');
self.onAddItem = exportGMFunc(self.onAddItem, "GM_NGFilter_onAddItem");
self.onRemoveItem = exportGMFunc(self.onRemoveItem, "GM_NGFilter_onRemoveItem");
self.onApplyConfig = exportGMFunc(self.onApplyConfig, "GM_NGFilter_onApplyItem");
self.onCancelConfig = exportGMFunc(self.onCancelConfig, "GM_NGFilter_onCancelItem");
self.onTest = exportGMFunc(self.onTest, "GM_NGFilter_onTest");
self.container = $e('<div id="NGConfig">\
<div class="list"></div>\
<div class="test"><input type="text" class="text" /><button class="button done">テスト</button></div>\
<div class="command"><button class="button add">追加</button><button class="button apply">適応</button><button class="button cancel">キャンセル</button></div>\
<div>', document.body)[0];
$(".button.add", self.container).bind("click", self.onAddItem);
$(".list", self.container).bind("click", self.onRemoveItem, true);
$(".button.apply", self.container).bind("click", self.onApplyConfig);
$(".button.cancel", self.container).bind("click", self.onCancelConfig);
$(".button.done", self.container).bind("click", self.onTest);
self.enable = options.enable;
self.setFilters(options.filters);
}
}
}
// 映像のアスペクト比を維持して全体を映す
function screenFix() {
var screen = $("object").parentNode,
height = window.innerHeight,
width = window.innerWidth,
resizedWidth, resizedHeight;
if (height < width * 9 / 16) {
resizedHeight = height;
resizedWidth = resizedHeight * 16 / 9;
} else {
resizedWidth = width;
resizedHeight = resizedWidth * 9 / 16;
}
screen.style.height = resizedHeight + "px";
screen.style.width = resizedWidth + "px";
screen.style.left = (width - resizedWidth) / 2 + "px";
}
/**************************************
* AbemaTV用のの汎用メソッド
**************************************/
// DataLayerのイベント
var events = {
gtmJs: "gtm.js",
gtmLoad: "gtm.load",
linkClick: "gtm.linkClick",
pageView: "pageview",
changedWindowSize: "changedWindowSize",
slotId: "metaData-slotId",
};
// 最新のデータを取得する
function getDataLayer(eventName) {
var data = window.dataLayer;
if (data) {
for (var i = data.length - 1; i >= 0; i--) {
if (data[i].event == eventName) {
return data[i];
}
}
}
}
// AbemaTVのOAuthトークンを取得する
function getAbmToken() {
if (localStorage){
if(localStorage.getItem){
return localStorage.getItem("abm_token");
} else {
return localStorage["abm_token"];
}
}
}
// Twitter連携のOAuthトークンを取得する
function getTwitterObject () {
var token, tokenSecret;
if (localStorage) {
if (localStorage.getItem) {
token = localStorage.getItem("abm_twitterToken");
tokenSecret = localStorage.getItem("abm_twitterTokenSecret");
} else {
token = localStorage["abm_twitterToken"];
tokenSecret = localStorage["abm_twitterTokenSecret"]
}
return {
accessToken: token,
accessTokenSecret: tokenSecret,
}
}
}
/****************************
* 初期化
****************************/
var initilized = false;
function onOnAirPage(data) {
log("onOnAirPage()");
settings.init();
loader.init();
loader.channel = data.channelId;
loader.slotId = null;
poster.init({
isShare: settings.get("twitter")
});
scroller.init({
enable: settings.get("scroll"),
speed: settings.get("scrollspeed"),
fontSize: settings.get("scrollfontsize"),
maxLine: settings.get("scrollmaxline"),
});
floater.init({
enable: settings.get("float"),
isReverse: settings.get("floatreverse"),
limit: settings.get("floatlimit"),
});
schedule.init();
zapping.init({
enable: settings.get("wheelzapping")
});
ng.init({
enable: settings.get("ng"),
filters: settings.get("ngfilter")
});
if (!initilized) {
initilized = true;
settings.addListener("scroll", function (key, value) {
scroller.enable = value;
if (value) {
scroller.container.removeClass("disable");
} else {
scroller.container.addClass("disable");
}
});
settings.addListener("float", function (key, value) {
floater.enable = value;
if (value) {
floater.container.removeClass("disable");
} else {
floater.container.addClass("disable");
}
});
settings.addListener("floatreverse", function (key, value) {
log(value)
var oldValue = floater.isReverse;
floater.isReverse = value;
if (oldValue != value) {
floater.reverse();
}
if (value) {
floater.container.addClass("reverse");
} else {
floater.container.removeClass("reverse");
}
});
settings.addListener("floatlimit", function (key, value) {
var container = floater.container;
floater.limit = value;
var overflowCount = container.childNodes.length - value;
if (overflowCount > 0) {
for (var i = 0; i < overflowCount; i++) {
if (floater.isReverse) {
// 昇順
container.removeChild(container.firstChild);
} else {
// 降順
container.removeChild(container.lastChild);
}
}
}
});
settings.addListener("scrollfontsize", function (key, value) {
scroller.fontSize = value;
});
settings.addListener("scrollspeed", function (key, value) {
scroller.speed = value;
});
settings.addListener("twitter", function (key, value) {
poster.isShare = value;
});
settings.addListener("font", function (key, value) {
scroller.container.style.fontFamily = value;
floater.container.style.fontFamily = value;
});
settings.addListener("scrollmaxline", function (key, value) {
scroller.maxLine = value;
});
settings.addListener("wheelzapping", function (key, value) {
zapping.enable = value;
});
settings.addListener("ng", function (key, value) {
ng.enable = value;
});
loader.onLoaded = onCommentLoaded;
loader.start();
window.bind("resize", exportGMFunc(screenFix, "GM_ANLCS_screenFix"));
}
setTimeout(screenFix, 1000);
log("end onOnAirPage()")
}
function onCommentLoaded(comment) {
if (ng.enable && ng.match(comment.message)) {
log("NG: " + comment.message);
return;
}
floater && floater.push(comment);
scroller && scroller.scroll(comment);
}
function onSlotIdChanged(data) {
log("onSlotIdChanged(): " + data.slotId);
var slotId = data.slotId;
if (slotId) {
loader.slotId = slotId;
poster.slotId = slotId;
}
}
function onChannelChanged(data) {
log("onChannelChanged(): " + data.channelId);
loader.channel = data.channelId;
loader.slotId = null;
}
function onOtherPage(data) {
log("onOtherPage(): " + data.uri);
loader.channel = null;
loader.slotId = null;
// dispose
poster.hide();
scroller.hide();
floater.hide();
}
// ページ遷移の監視
var lastChannelId = "";
var lastPage = "";
var lastSlotId = "";
setInterval(function () {
try {
for (var i = window.dataLayer.length - 1; i >= 0; i--) {
// pageview
var pageViewData = getDataLayer(events.pageView);
if (pageViewData) {
// page changed
if (lastPage != pageViewData.uri) {
log("pageview: " + pageViewData.uri);
// broadcast page
if (pageViewData.uri.indexOf("/now-on-air/") == 0) {
if (lastPage.indexOf("/now-on-air/") != 0) {
onOnAirPage(pageViewData);
}
// channel changed
if (lastChannelId != pageViewData.channelId) {
onChannelChanged(pageViewData);
lastChannelId = pageViewData.channelId;
}
} else {
// other page
onOtherPage(pageViewData);
}
lastPage = pageViewData.uri;
}
}
// slotid
var slotIdData = getDataLayer(events.slotId);
if (slotIdData) {
// slotid changed
if (lastSlotId != slotIdData.slotId) {
log("slotid: " + slotIdData.slotId);
onSlotIdChanged(slotIdData);
lastSlotId = slotIdData.slotId;
}
}
}
} catch (ex) { log(ex) }
}, 1234);
log("loaded");
})()