Greasy Fork is available in English.

AbemaTV, ニコニコ風コメントスクロール

AbemaTVのコメントをスクリーン上に表示、またはニコニコ動画風にスクロールするスクリプト

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

// ==UserScript==
// @name        AbemaTV, ニコニコ風コメントスクロール
// @description AbemaTVのコメントをスクリーン上に表示、またはニコニコ動画風にスクロールするスクリプト
// @namespace   https://greasyfork.org/users/1242
// @include     https://abema.tv/now-on-air/*
// @version     0.3.2
// @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 () {
    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();
    /**************************************
     *  汎用メソッド
     **************************************/
    // 関数を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"), "");
    }
    // HTMLエスケープ
    var escapeHtml = (function () {
        var escapeMap = {
            '&': '&amp;',
            "'": '&#x27;',
            '`': '&#x60;',
            '"': '&quot;',
            '<': '&lt;',
            '>': '&gt;'
        };
        var escapeReg = '[';
        var reg;
        for (var p in escapeMap) {
            if (escapeMap.hasOwnProperty(p)) {
                escapeReg += p;
            }
        }
        escapeReg += ']';
        reg = new RegExp(escapeReg, 'g');
        return function (str) {
            str = (str === null || str === void 0) ? '' : '' + str;
            return str.replace(reg, function (match) {
                return escapeMap[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 getRandom(min, max) {
        return Math.random() * (max - min) + min;
    }
    /**************************************
     *  スクリプトの実装
     **************************************/
    // localStorage に保存した設定のラッパー
    function Settings() {
        var self = this;
        var Default = function () { }
        Default.prototype = {
            float: true,
            scroll: true,
            scrollfontsize: 28,
            scrollspeed: 5000,
            scrollmaxline: 8,
            twitter: false,
            wheelzapping: false,
            keepaspect: true,
        }
        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] != "undfined") {
                                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 "twitter":
                case "wheelzapping":
                    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 "scrollspeed":
                    var speed = parseInt(args[1]);
                    if (2000 <= speed && speed <= 10000) {
                        return settings.set(key, speed);
                    } else {
                        throw new RangeError("2000から10000までの間でなければなりません");
                    }
                    break;
                case "scrollfontsize":
                    var size = parseInt(args[1]);
                    if (16 <= size && size <= 40) {
                        return settings.set(key, size);
                    } else {
                        throw new RangeError("16から40までの間でなければなりません");
                    }
                    break;
                case "scrollmaxline":
                    var line = parseInt(args[1]);
                    if (5 <= line && line <= 20) {
                        return settings.set(key, line);
                    } else {
                        throw new RangeError("5から20までの間でなければなりません");
                    }
                    break;
                case "font":
                    return settings.set(cmd0, 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(data);
        }
        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.channel = 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._loadCommentOptionsRequest = function (callback) {
            GM_xmlhttpRequest({
                url: stringMap(self.COMMENT_API, {
                    slotId: self.slotId,
                    limit: 20
                }),
                method: "OPTIONS",
                headers: {
                    "Access-Control-Request-Method": "GET",
                    "Access-Control-Request-Headers": "authorization,content-type",
                },
                onload: callback,
            });
        }
        // コメント取得のリクエストを送信する
        this._loadCommentRequest = function (callback) {
            GM_xmlhttpRequest({
                url: stringMap(self.COMMENT_API, {
                    slotId: self.slotId,
                    limit: 20
                }),
                method: "GET",
                headers: {
                    "Authorization": "bearer " + getAbmToken(),
                },
                onload: callback,
            })
        }
        // コメントを取得する
        this._loadComment = function () {
            lastLoadedTime = new Date().getTime();
            var channel = self.channel;
            var slotId = self.slotId;

            // コメント読み込み
            self._loadCommentRequest(function (e) {
                // コメントを読み込む前にOPTIONSメソッドで一回叩く必要がある
                // 404が返ってきたら、初期化読み込み後に再度コメント読み込み
                if (e.status == 404) {
                    self._loadCommentOptionsRequest(function () {
                        self._loadCommentRequest(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.trim());
                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 {
                // スロットIDを取得
                var slotId = getSlotId();
                if (slotId) {
                    self.slotId = slotId;
                } else if (!self.slotId) {
                    return;
                }

                // チャンネルを移動したら、コメントを取得
                var channel = getChannel();
                if (self.channel && self.channel == channel) {
                    // コメント数
                    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;
                    }
                } else {
                    self.channel = channel;
                }
                self._loadComment();
            } 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()")
            self._getCountElement();
        }
        this.onLoaded = null;
        log("CommentLoader()");
    }

    // コメントフロート表示を実装
    function CommentFloat() {
        var self = this;
        var lastSlotId = null;

        this.container;
        this.limits = 50;
        // コメントを追加する
        this.push = function (comment) {
            if (!self.enable) return;

            var isSlotIdChanged = lastSlotId != comment.slotId;
            lastSlotId = comment.slotId;

            // 除去
            if (self.limits <= self.container.childNodes.length) {
                self.container.removeChild(self.container.lastChild);
            }
            // 追加
            self.container.insertBefore($e(stringMap('<span class="comment{isFirst}" channel="{channel}" uid="{uid}">{message}</span>', {
                message: escapeHtml(comment.message),
                channel: escapeHtml(comment.channel),
                uid: escapeHtml(comment.id),
                isFirst: isSlotIdChanged ? " isFirst" : ""
            }))[0], self.container.firstChild);
        }
        // コメントを全て除去する
        this.clear = function () {
            while (self.container.firstChild) {
                self.container.removeChild(self.container.firstChild);
            }
        }
        // 初期化する
        this.init = function (options) {
            log("CommentFloat.init()")
            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-bottom: 1px solid rgba(255,255,255,0.05); word-break: break-all; }\
#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;
        }
        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;
        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;                                          // スクロールする行

            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 = getRandom(0, maxLine - 1);
                    renderDelay = 0;
                    break;
                }
            }

            comments[renderLine].count++;
            comments[renderLine].commentWidth = commentWidth;
            comments[renderLine].scrollEndTime = now + renderDelay + duration;
            comments[renderLine].commentFullViewTime = now + renderDelay + commentScrollDuration;

            setTimeout(function () {
                c.style.transition = stringMap("left {0}ms linear", { 0: duration + marginScrollDuration });
                c.style.top = (renderLine * self.fontSize * 1.1 + 45) + "px";
                c.style.left = "-" + (commentWidth + marginWidth) + "px";
                
                setTimeout(function () {
                    comments[renderLine].count--;
                    self.container.removeChild(c);
                }, duration);
            }, renderDelay);
        }
        // 初期化する
        this.init = function (options) {
            log("CommentScroll.init()")
            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;
        }
        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.COMMENT_LIMIT = 50;
        this.POST_API = "https://api.abema.io/v1/slots/{slotId}/comments";
        this.slotId;
        this.isShare;
        
        // コメント投稿する
        this.post = function (message) {
            log(message);
            message = message.trim();
            if (message.indexOf("/") == 0) {
                try{
                    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 = getSlotId() || 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);
            }
        }
        // 初期化する
        this.init = function (options) {
            log("CommentPost.init()")
            try {
                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; }\
");
                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);
                var sideForm = $('[class^="TVContainer__right-comment-area"] form');
                $("textarea", sideForm).setAttribute("placeholder", "コメントを入力、設定コマンドは右下コメント欄へ");

                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);
                
            } catch (ex) {
                log(ex);
            }
        }
        log("CommentPost()");
    }
    // スケジュール表示を実装
    function Schedule() {
        var self = this;
        var closeTimer;
        var DAY_TIMES = 24 * 60 * 60 * 1000;
        var httpLoading = false;
        var lastViewChannel = 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 todayKey = self.getDateKey(new Date());
            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, todayKey);
                                }
                            } catch (ex) {
                                log(ex);
                            }
                        }
                        httpLoading = false;
                    });
                });
            }
        }
        // 初期化する
        this.init = function () {
            log("Schedule.init()");
            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;
        this.init = function (options) {
            var overrap = $("[class*='style__overlap']");
            if (overrap) {
                overrap.bind("wheel mousewheel DOMMouseScroll", exportGMFunc(function (e) {
                    if (self.enable) {
                        e.stopImmediatePropagation();
                        e.preventDefault();
                        e.stopPropagation();
                    }
                }, "GM_ANLCS_overrap_onMousewheel"), true);
            }
            self.enable = options.enable;
        }
    }
    // 映像のアスペクト比を維持して全体を映す
    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用のの汎用メソッド
     **************************************/
    // 最新のデータを取得する
    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];
                }
            }
        }
    }
    var lastSlotId;
    // スロットIDを取得する
    function getSlotId() {
        var data = getDataLayer("metaData-slotId");
        if (data) {
            if (data.slotId) {
                return lastSlotId = data.slotId;
            }
        }
    }
    // チャンネルIDを取得する
    function getChannel() {
        var data = getDataLayer("pageview");
        if (data) {
            return data.channelId;
        }
    }
    // 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,
            }
        }
    }
    /****************************
     * 初期化
     ****************************/
    function init() {
        log("init()");

        settings.init();
        loader.init();
        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"),
        });
        schedule.init();
        zapping.init({
            enable: settings.get("wheelzapping")
        });

        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("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 = escapeHtml(value);
            floater.container.style.fontFamily = escapeHtml(value);
        });
        settings.addListener("scrollmaxline", function (key, value) {
            scroller.maxLine = value;
        });
        settings.addListener("wheelzapping", function (key, value) {
            zapping.enable = value;
        });
        loader.onLoaded = function (c) {
            floater && floater.push(c);
            scroller && scroller.scroll(c);
        }
        loader.start();

        window.bind("resize", exportGMFunc(screenFix, "GM_screenFix"));
        setTimeout(screenFix, 1000);

        log("end init()")
    }
    // ページの初期化を待つ
    var readyTimer = setInterval(function () {
        if (document.readyState == "complete") {
            var data = getDataLayer("pageview");
            if (data) {
                clearInterval(readyTimer);
                init();
            }
        }
    }, 500);

    log("loaded");
})()