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

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

Fra og med 24.05.2016. Se den nyeste version.

// ==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 = {
            '&': '&amp;',
            "'": '&#x27;',
            '`': '&#x60;',
            '"': '&quot;',
            '<': '&lt;',
            '>': '&gt;'
        };
        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");
})()