Anime Comment Overlay

Display overlay of comments on various streaming sites and EPGStation.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name            Anime Comment Overlay
// @namespace       https://github.com/SlashNephy
// @version         0.4.1
// @author          SlashNephy
// @description     Display overlay of comments on various streaming sites and EPGStation.
// @description:ja  アニメ配信サイト (dアニメストア / ABEMAビデオ / Netflix) や EPGStation で実況コメをオーバーレイ表示します。
// @homepage        https://scrapbox.io/slashnephy/%E3%82%A2%E3%83%8B%E3%83%A1%E9%85%8D%E4%BF%A1%E3%82%B5%E3%82%A4%E3%83%88%E3%81%A7%E5%AE%9F%E6%B3%81%E3%82%B3%E3%83%A1%E3%82%92%E3%82%AA%E3%83%BC%E3%83%90%E3%83%BC%E3%83%AC%E3%82%A4%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript
// @homepageURL     https://scrapbox.io/slashnephy/%E3%82%A2%E3%83%8B%E3%83%A1%E9%85%8D%E4%BF%A1%E3%82%B5%E3%82%A4%E3%83%88%E3%81%A7%E5%AE%9F%E6%B3%81%E3%82%B3%E3%83%A1%E3%82%92%E3%82%AA%E3%83%BC%E3%83%90%E3%83%BC%E3%83%AC%E3%82%A4%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B_UserScript
// @icon            https://www.google.com/s2/favicons?sz=64&domain=animestore.docomo.ne.jp
// @supportURL      https://github.com/SlashNephy/userscripts/issues
// @match           https://animestore.docomo.ne.jp/animestore/sc_d_pc?partId=*
// @match           https://abema.tv/video/episode/*
// @match           https://www.netflix.com/watch/*
// @match           *://*/*
// @require         https://cdn.jsdelivr.net/npm/@xpadev-net/[email protected]/dist/bundle.min.js
// @require         https://cdn.jsdelivr.net/gh/NaturalIntelligence/fast-xml-parser@ecf6016f9b48aec1a921e673158be0773d07283e/lib/fxp.min.js
// @connect         cal.syoboi.jp
// @grant           GM_xmlhttpRequest
// @license         MIT license
// ==/UserScript==

(function (NiconiComments, fastXmlParser) {
    'use strict';

    const AnnictSupportedVodChannelIds = {
        bandai: 107,
        niconico: 165,
        dAnime: 241,
        amazonPrimeVideo: 243,
        netflix: 244,
        abemaVideo: 260,
        dAnimeNiconico: 306,
    };
    const ChannelCmAttributes = {
        jk1: null,
        jk2: null,
        jk4: {
            head: 60,
            sponsor: 5,
            normal: 150,
        },
        jk5: {
            head: 60 + 3,
            sponsor: 5,
            normal: 105,
        },
        jk6: {
            head: 0,
            sponsor: 10,
            normal: 135,
        },
        jk7: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk8: {
            head: 120,
            sponsor: 10,
            normal: 90,
        },
        jk9: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk10: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk11: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk12: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk101: null,
        jk103: null,
        jk141: {
            head: 15,
            sponsor: 10,
            normal: 60,
        },
        jk151: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk161: {
            head: 3,
            sponsor: 10,
            normal: 60,
        },
        jk171: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk181: {
            head: 15,
            sponsor: 5,
            normal: 135,
        },
        jk191: null,
        jk192: null,
        jk193: null,
        jk211: {
            head: 0,
            sponsor: 10,
            normal: 60,
        },
        jk222: {
            head: 10,
            sponsor: 10,
            normal: 150,
        },
        jk236: {
            head: 10,
            sponsor: 10,
            normal: 45,
        },
        jk252: null,
        jk260: {
            head: 2,
            sponsor: 10,
            normal: 120,
        },
        jk263: {
            head: 0,
            sponsor: 10,
            normal: 180,
        },
        jk265: {
            head: 10,
            sponsor: 10,
            normal: 60,
        },
        jk333: null,
    };
    const vposAdjustment = 50;
    const partSymbols = ['A', 'B', 'C'];
    const partSymbolCommentsThreshold = 2;
    const partSymbolAdjustment = 3;
    const opSymbols = ['OP'];
    const opSymbolCommentsThreshold = 2;
    const opLength = 90;
    const opAdjustment = 30;
    const copyrightCmAttributes = [
        {
            pattern: /^©BNP\//,
            adjustment: 3,
        },
    ];
    const maxPrograms = 5;
    const targetFps = 100;

    async function awaitElement(selectors) {
        return new Promise((resolve) => {
            const element = document.querySelector(selectors);
            if (element !== null) {
                resolve(element);
                return;
            }
            const observer = new MutationObserver(() => {
                const e = document.querySelector(selectors);
                if (e !== null) {
                    resolve(e);
                    observer.disconnect();
                }
            });
            observer.observe(document.body, {
                childList: true,
                subtree: true,
            });
        });
    }

    async function fetchAnnictBroadcastData(branch = 'master') {
        const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/anime-vod-data/${branch}/dist/data.json`);
        return response.json();
    }

    let observer = null;
    const AbemaVideoOverlay = {
        id: 'abema-video',
        name: 'ABEMAビデオ',
        url: /^https:\/\/abema\.tv\/video\/episode\/([\w-]+)/,
        initializeContainers() {
            const video = () => document.querySelector('video[preload="metadata"]');
            const canvas = document.createElement('canvas');
            canvas.width = 1920;
            canvas.height = 1080;
            canvas.style.position = 'relative';
            canvas.style.objectFit = 'contain';
            canvas.style.width = '100%';
            canvas.style.height = '100%';
            canvas.style.zIndex = '10';
            awaitElement('.com-vod-VODScreen-video-cover')
                .then((cover) => {
                cover.appendChild(canvas);
            })
                .catch((e) => {
                console.error(`[anime-comment-overlay] failed to find cover element: ${e}`);
            });
            return { video, canvas };
        },
        async detectMedia(id) {
            const [titleId, episodeId] = id.split('_', 2);
            if (!titleId || !episodeId) {
                throw new Error(`unexpected id format: ${id}`);
            }
            const title = document.querySelector('.com-video-EpisodeTitleBlock__series-info')?.textContent;
            if (!title) {
                throw new Error('title container not found');
            }
            const episode = document.querySelector('.com-video-EpisodeTitleBlock__title')?.textContent;
            if (!episode) {
                throw new Error('episode container not found');
            }
            let [episodeNumber, episodeTitle] = episode.split(' ', 2);
            if (!episodeNumber || !episodeTitle) {
                episodeNumber = episode;
                episodeTitle = episode;
            }
            const broadcasts = await fetchAnnictBroadcastData();
            return {
                work: {
                    title,
                    annictIds: broadcasts
                        .filter((x) => x.channel_id === AnnictSupportedVodChannelIds.abemaVideo && x.vod_code === titleId)
                        .map((x) => x.work_id),
                },
                episode: {
                    title: episodeTitle,
                    number: episodeNumber,
                },
            };
        },
        addEventListener(event, callback) {
            switch (event) {
                case 'mediaChanged': {
                    if (observer !== null) {
                        observer.disconnect();
                        observer = null;
                    }
                    const target = document.querySelector('.com-video-EpisodeTitleBlock__title');
                    if (target === null) {
                        throw new Error('target container not found');
                    }
                    observer = new MutationObserver((mutations) => {
                        for (const mutation of mutations) {
                            if (mutation.type === 'characterData' && mutation.target === target) {
                                callback();
                            }
                        }
                    });
                    observer.observe(target, { characterData: true, subtree: true });
                }
            }
        },
        removeEventListener(event) {
            switch (event) {
                case 'mediaChanged': {
                    observer?.disconnect();
                    observer = null;
                }
            }
        },
    };

    async function fetchDanimePartInfo(partId) {
        const response = await fetch(`https://animestore.docomo.ne.jp/animestore/rest/WS030101?partId=${partId}`);
        return response.json();
    }

    const DanimeOverlay = {
        id: 'danime-store',
        name: 'dアニメストア',
        url: /^https:\/\/animestore\.docomo\.ne\.jp\/animestore\/sc_d_pc\?partId=(\d+)/,
        initializeContainers() {
            const canvas = document.createElement('canvas');
            canvas.width = 1920;
            canvas.height = 1080;
            canvas.style.position = 'relative';
            canvas.style.objectFit = 'contain';
            canvas.style.width = '100%';
            canvas.style.height = '100%';
            canvas.style.zIndex = '10';
            awaitElement('video#video')
                .then((video) => {
                video.insertAdjacentElement('afterend', canvas);
            })
                .catch((e) => {
                console.error(`[anime-comment-overlay] failed to find video element: ${e}`);
            });
            const video = () => document.querySelector('video#video');
            return { video, canvas };
        },
        async detectMedia(partId) {
            const info = await fetchDanimePartInfo(partId);
            const broadcasts = await fetchAnnictBroadcastData();
            return {
                work: {
                    title: info.workTitle,
                    annictIds: broadcasts
                        .filter((x) => x.channel_id === AnnictSupportedVodChannelIds.dAnime && x.vod_code === info.workId)
                        .map((x) => x.work_id),
                    copyright: info.partCopyright,
                },
                episode: {
                    title: info.partTitle,
                    number: info.partDispNumber,
                },
            };
        },
        addEventListener(event, callback) {
            switch (event) {
                case 'mediaChanged':
                    $('.backInfoTxt3').on('DOMSubtreeModified propertychange', callback);
            }
        },
        removeEventListener(event, callback) {
            switch (event) {
                case 'mediaChanged':
                    $('.backInfoTxt3').off('DOMSubtreeModified propertychange', callback);
            }
        },
    };

    async function fetchEpgStationRecordedItem(id) {
        const response = await fetch(`/api/recorded/${id}?isHalfWidth=true`);
        return await response.json();
    }
    async function fetchEpgStationChannels() {
        const response = await fetch('/api/channels');
        return await response.json();
    }

    const EpgStationOnAirOverlay = {
        id: 'epgstation-onair',
        name: 'EPGStation (ライブ)',
        url: /^https?:\/\/.+\/#\/onair\/watch/,
        initializeContainers() {
            throw new Error('not implemented');
        },
        async detectMedia(partId) {
            throw new Error('not implemented');
        },
        addEventListener(event) {
        },
        removeEventListener(event) {
        },
    };
    const EpgStationRecordedOverlay = {
        id: 'epgstation-recorded',
        name: 'EPGStation (録画番組)',
        url: /^https?:\/\/.+\/#\/recorded\/streaming\/\d+/,
        initializeContainers() {
            const canvas = document.createElement('canvas');
            canvas.width = 1920;
            canvas.height = 1080;
            canvas.style.position = 'absolute';
            canvas.style.objectFit = 'contain';
            canvas.style.width = '100%';
            canvas.style.height = '100%';
            canvas.style.zIndex = '10';
            awaitElement('.video-wrap video')
                .then((video) => {
                video.insertAdjacentElement('beforebegin', canvas);
            })
                .catch((e) => {
                console.error(`[anime-comment-overlay] failed to find video element: ${e}`);
            });
            const video = () => document.querySelector('.video-wrap video');
            return { video, canvas };
        },
        async detectMedia() {
            const queries = new URLSearchParams(window.location.hash.split('?')[1]);
            const recordedId = queries.get('recordedId');
            if (recordedId === null) {
                throw new Error('recordedId is null');
            }
            const recorded = await fetchEpgStationRecordedItem(recordedId);
            const channels = await fetchEpgStationChannels();
            const channel = channels.find((x) => x.id === recorded.channelId);
            if (channel === undefined) {
                throw new Error('failed to find channel');
            }
            return {
                video: {
                    channel: {
                        type: channel.channelType,
                        serviceId: channel.serviceId,
                    },
                    startedAt: new Date(recorded.startAt),
                    endedAt: new Date(recorded.endAt),
                },
            };
        },
        addEventListener(event) {
        },
        removeEventListener(event) {
        },
    };

    const executeGmXhr = async (request) => new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            ...request,
            onload: (response) => {
                resolve(response);
            },
            onerror: (error) => {
                reject(error);
            },
        });
    });

    async function fetchNetflixMediaMetadata(baseUrl, episodeId) {
        const { responseText } = await executeGmXhr({
            method: 'GET',
            url: `${baseUrl}/metadata?movieid=${episodeId}`,
        });
        return JSON.parse(responseText);
    }

    const NetflixOverlay = {
        id: 'netflix',
        name: 'Netflix',
        url: /^https:\/\/www\.netflix.com\/watch\/(\d+)/,
        initializeContainers() {
            const canvas = document.createElement('canvas');
            canvas.width = 1920;
            canvas.height = 1080;
            canvas.style.position = 'relative';
            canvas.style.objectFit = 'contain';
            canvas.style.width = '100%';
            canvas.style.height = '100%';
            canvas.style.zIndex = '10';
            awaitElement('video')
                .then((video) => {
                video.insertAdjacentElement('afterend', canvas);
            })
                .catch((e) => {
                console.error(`[anime-comment-overlay] failed to find video element: ${e}`);
            });
            const video = () => document.querySelector('video');
            return { video, canvas };
        },
        async detectMedia(episodeId) {
            const reactContextScript = Array.from(document.getElementsByTagName('script')).find((e) => e.textContent?.includes('reactContext') === true);
            if (reactContextScript === undefined) {
                throw new Error('failed to find reactContext script');
            }
            const reactContextJson = reactContextScript.textContent
                ?.replace(/^.+reactContext = (.+);$/, '$1')
                .replace(/\\x(.{2})/g, (_, x) => String.fromCharCode(parseInt(x, 16)));
            if (reactContextJson === undefined) {
                throw new Error('failed to extract reactContext json');
            }
            const context = JSON.parse(reactContextJson);
            const metadata = await fetchNetflixMediaMetadata(`${context.models.services.data.memberapi.protocol}://${context.models.services.data.memberapi.hostname}${context.models.services.data.memberapi.path[0]}`, episodeId);
            const episode = metadata.video.seasons
                .flatMap((s) => s.episodes)
                .find((e) => e.episodeId === metadata.video.currentEpisode);
            if (episode === undefined) {
                throw new Error('failed to find episode');
            }
            const broadcasts = await fetchAnnictBroadcastData();
            return {
                work: {
                    title: metadata.video.title,
                    annictIds: broadcasts
                        .filter((x) => x.channel_id === AnnictSupportedVodChannelIds.netflix && x.vod_code === metadata.video.id.toString())
                        .map((x) => x.work_id),
                },
                episode: {
                    title: episode.title,
                    number: episode.seq,
                },
            };
        },
        addEventListener(event, callback) {
            switch (event) {
                case 'mediaChanged':
                    document.addEventListener('popstate', callback);
            }
        },
        removeEventListener(event, callback) {
            switch (event) {
                case 'mediaChanged': {
                    document.removeEventListener('popstate', callback);
                }
            }
        },
    };

    var dist = {};

    var utils = {};

    var oldJapaneseNumerics$1 = {};

    Object.defineProperty(oldJapaneseNumerics$1, "__esModule", {
      value: true
    });
    const oldJapaneseNumerics = {
      零: '〇',
      壱: '一',
      壹: '一',
      弐: '二',
      弍: '二',
      貳: '二',
      貮: '二',
      参: '三',
      參: '三',
      肆: '四',
      伍: '五',
      陸: '六',
      漆: '七',
      捌: '八',
      玖: '九',
      拾: '十',
      廿: '二十',
      陌: '百',
      佰: '百',
      阡: '千',
      仟: '千',
      萬: '万'
    };
    oldJapaneseNumerics$1.default = oldJapaneseNumerics;

    var japaneseNumerics$1 = {};

    Object.defineProperty(japaneseNumerics$1, "__esModule", {
      value: true
    });
    const japaneseNumerics = {
      〇: 0,
      一: 1,
      二: 2,
      三: 3,
      四: 4,
      五: 5,
      六: 6,
      七: 7,
      八: 8,
      九: 9,
      '0': 0,
      '1': 1,
      '2': 2,
      '3': 3,
      '4': 4,
      '5': 5,
      '6': 6,
      '7': 7,
      '8': 8,
      '9': 9
    };
    japaneseNumerics$1.default = japaneseNumerics;

    (function (exports) {

      Object.defineProperty(exports, "__esModule", {
        value: true
      });
      exports.zen2han = exports.n2kan = exports.kan2n = exports.splitLargeNumber = exports.normalize = exports.smallNumbers = exports.largeNumbers = void 0;
      const oldJapaneseNumerics_1 = oldJapaneseNumerics$1;
      const japaneseNumerics_1 = japaneseNumerics$1;
      exports.largeNumbers = {
        '兆': 1000000000000,
        '億': 100000000,
        '万': 10000
      };
      exports.smallNumbers = {
        '千': 1000,
        '百': 100,
        '十': 10
      };
      function normalize(japanese) {
        for (const key in oldJapaneseNumerics_1.default) {
          const reg = new RegExp(key, 'g');
          japanese = japanese.replace(reg, oldJapaneseNumerics_1.default[key]);
        }
        return japanese;
      }
      exports.normalize = normalize;
      /**
       * 漢数字を兆、億、万単位に分割する
       */
      function splitLargeNumber(japanese) {
        let kanji = japanese;
        const numbers = {};
        for (const key in exports.largeNumbers) {
          const reg = new RegExp(`(.+)${key}`);
          const match = kanji.match(reg);
          if (match) {
            numbers[key] = kan2n(match[1]);
            kanji = kanji.replace(match[0], '');
          } else {
            numbers[key] = 0;
          }
        }
        if (kanji) {
          numbers['千'] = kan2n(kanji);
        } else {
          numbers['千'] = 0;
        }
        return numbers;
      }
      exports.splitLargeNumber = splitLargeNumber;
      /**
       * 千単位以下の漢数字を数字に変換する(例: 三千 => 3000)
       *
       * @param japanese
       */
      function kan2n(japanese) {
        if (japanese.match(/^[0-9]+$/)) {
          return Number(japanese);
        }
        let kanji = zen2han(japanese);
        let number = 0;
        for (const key in exports.smallNumbers) {
          const reg = new RegExp(`(.*)${key}`);
          const match = kanji.match(reg);
          if (match) {
            let n = 1;
            if (match[1]) {
              if (match[1].match(/^[0-9]+$/)) {
                n = Number(match[1]);
              } else {
                n = japaneseNumerics_1.default[match[1]];
              }
            }
            number = number + n * exports.smallNumbers[key];
            kanji = kanji.replace(match[0], '');
          }
        }
        if (kanji) {
          if (kanji.match(/^[0-9]+$/)) {
            number = number + Number(kanji);
          } else {
            for (let index = 0; index < kanji.length; index++) {
              const char = kanji[index];
              const digit = kanji.length - index - 1;
              number = number + japaneseNumerics_1.default[char] * 10 ** digit;
            }
          }
        }
        return number;
      }
      exports.kan2n = kan2n;
      /**
       * Converts number less than 10000 to kanji.
       *
       * @param num
       */
      function n2kan(num) {
        const kanjiNumbers = Object.keys(japaneseNumerics_1.default);
        let number = num;
        let kanji = '';
        for (const key in exports.smallNumbers) {
          const n = Math.floor(number / exports.smallNumbers[key]);
          if (n) {
            number = number - n * exports.smallNumbers[key];
            if (1 === n) {
              kanji = `${kanji}${key}`;
            } else {
              kanji = `${kanji}${kanjiNumbers[n]}${key}`;
            }
          }
        }
        if (number) {
          kanji = `${kanji}${kanjiNumbers[number]}`;
        }
        return kanji;
      }
      exports.n2kan = n2kan;
      /**
       * Converts double-width number to number as string.
       *
       * @param num
       */
      function zen2han(str) {
        return str.replace(/[0-9]/g, s => {
          return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
        });
      }
      exports.zen2han = zen2han;
    })(utils);

    Object.defineProperty(dist, "__esModule", {
      value: true
    });
    var findKanjiNumbers_1 = dist.findKanjiNumbers = dist.number2kanji = kanji2number_1 = dist.kanji2number = void 0;
    const utils_1 = utils;
    const japaneseNumerics_1 = japaneseNumerics$1;
    function kanji2number(japanese) {
      japanese = (0, utils_1.normalize)(japanese);
      if (japanese.match('〇') || japanese.match(/^[〇一二三四五六七八九]+$/)) {
        for (const key in japaneseNumerics_1.default) {
          const reg = new RegExp(key, 'g');
          japanese = japanese.replace(reg, japaneseNumerics_1.default[key].toString());
        }
        return Number(japanese);
      } else {
        let number = 0;
        const numbers = (0, utils_1.splitLargeNumber)(japanese);
        // 万以上の数字を数値に変換
        for (const key in utils_1.largeNumbers) {
          if (numbers[key]) {
            const n = utils_1.largeNumbers[key] * numbers[key];
            number = number + n;
          }
        }
        if (!Number.isInteger(number) || !Number.isInteger(numbers['千'])) {
          throw new TypeError('The attribute of kanji2number() must be a Japanese numeral as integer.');
        }
        // 千以下の数字を足す
        return number + numbers['千'];
      }
    }
    var kanji2number_1 = dist.kanji2number = kanji2number;
    function number2kanji(num) {
      if (!num.toString().match(/^[0-9]+$/)) {
        throw new TypeError('The attribute of number2kanji() must be integer.');
      }
      Object.keys(japaneseNumerics_1.default);
      let number = num;
      let kanji = '';
      // 万以上の数字を漢字に変換
      for (const key in utils_1.largeNumbers) {
        const n = Math.floor(number / utils_1.largeNumbers[key]);
        if (n) {
          number = number - n * utils_1.largeNumbers[key];
          kanji = `${kanji}${(0, utils_1.n2kan)(n)}${key}`;
        }
      }
      if (number) {
        kanji = `${kanji}${(0, utils_1.n2kan)(number)}`;
      }
      return kanji || '〇';
    }
    dist.number2kanji = number2kanji;
    function findKanjiNumbers(text) {
      const num = '([0-90-9]*)|([〇一二三四五六七八九壱壹弐弍貳貮参參肆伍陸漆捌玖]*)';
      const basePattern = `((${num})(千|阡|仟))?((${num})(百|陌|佰))?((${num})(十|拾))?(${num})?`;
      const pattern = `((${basePattern}兆)?(${basePattern}億)?(${basePattern}(万|萬))?${basePattern})`;
      const regex = new RegExp(pattern, 'g');
      const match = text.match(regex);
      if (match) {
        return match.filter(item => {
          if (!item.match(/^[0-90-9]+$/) && item.length && '兆' !== item && '億' !== item && '万' !== item && '萬' !== item) {
            return true;
          } else {
            return false;
          }
        });
      } else {
        return [];
      }
    }
    findKanjiNumbers_1 = dist.findKanjiNumbers = findKanjiNumbers;

    /**
     * Checks whether given array's length is equal to given number.
     *
     * @example
     * ```ts
     * hasLength(arr, 1) // equivalent to arr.length === 1
     * ```
     */
    /**
     * Checks whether given array's length is greather than or equal to given number.
     *
     * @example
     * ```ts
     * hasMinLength(arr, 1) // equivalent to arr.length >= 1
     * ```
     */
    function hasMinLength(arr, length) {
      return arr.length >= length;
    }

    async function fetchArmEntries(branch = 'master') {
        const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/arm-supplementary/${branch}/dist/arm.json`);
        return response.json();
    }

    async function fetchSayaDefinitions(branch = 'master') {
        const response = await fetch(`https://raw.githubusercontent.com/SlashNephy/saya-definitions/${branch}/definitions.json`);
        return response.json();
    }

    async function fetchSyobocalProgLookup(tids) {
        const { responseText } = await executeGmXhr({
            url: `https://cal.syoboi.jp/db.php?Command=ProgLookup&TID=${tids.join(',')}`,
        });
        const parser = new fastXmlParser.XMLParser();
        return parser.parse(responseText);
    }
    async function fetchSyobocalProgLookupWithRange(startTime, endTime, chId) {
        function zerofill(n) {
            return `00${n}`.slice(-2);
        }
        function format(d) {
            return `${d.getFullYear()}${zerofill(d.getMonth() + 1)}${zerofill(d.getDate())}_${zerofill(d.getHours())}${zerofill(d.getMinutes())}${zerofill(d.getSeconds())}`;
        }
        const { responseText } = await executeGmXhr({
            url: `https://cal.syoboi.jp/db.php?Command=ProgLookup&Range=${format(startTime)}-${format(endTime)}&ChID=${chId}`,
        });
        const parser = new fastXmlParser.XMLParser();
        return parser.parse(responseText);
    }

    async function findPrograms(media) {
        const saya = await fetchSayaDefinitions();
        const serviceId = media.video?.channel.serviceId;
        if (serviceId !== undefined && media.video !== undefined) {
            const chId = saya.channels.find((x) => x.type === media.video?.channel.type && x.serviceIds.includes(serviceId))
                ?.syobocalId;
            if (chId !== undefined) {
                const programs = await fetchSyobocalProgLookupWithRange(media.video.startedAt, media.video.endedAt, chId);
                return convertPrograms(programs, undefined, saya);
            }
        }
        if (media.work?.annictIds.length === 0) {
            return [];
        }
        const arm = await fetchArmEntries();
        const syobocalTids = arm
            .filter((e) => e.annict_id !== undefined && media.work?.annictIds.includes(e.annict_id))
            .map((e) => e.syobocal_tid)
            .filter((x) => x !== undefined)
            .filter((x, idx, array) => idx === array.indexOf(x));
        console.info(`[anime-comment-overlay] found syobocal tids: ${syobocalTids}`);
        const programs = await fetchSyobocalProgLookup(syobocalTids);
        const episodeNumber = extractEpisodeNumber(media.episode?.number);
        return convertPrograms(programs, episodeNumber, saya);
    }
    function convertPrograms(response, episodeNumber, saya) {
        const items = Array.isArray(response.ProgLookupResponse?.ProgItems?.ProgItem)
            ? response.ProgLookupResponse?.ProgItems?.ProgItem
            : [response.ProgLookupResponse?.ProgItems?.ProgItem];
        return (items
            ?.filter((p) => p !== undefined)
            .filter((p) => episodeNumber === undefined || p.Count === episodeNumber)
            ?.map((p) => {
            const startedAt = Date.parse(p.StTime) / 1000;
            if (Date.now() / 1000 < startedAt) {
                return null;
            }
            const endedAt = Date.parse(p.EdTime) / 1000;
            if (Date.now() / 1000 < endedAt) {
                return null;
            }
            const channel = saya.channels.find((c) => c.syobocalId === p.ChID);
            if (channel === undefined) {
                return null;
            }
            console.info(`[anime-comment-overlay] found program: ${channel.name} (${p.StTime} ~ ${p.EdTime})`);
            return {
                channel,
                startedAt,
                endedAt,
            };
        })
            ?.filter((x) => x !== null)
            ?.sort((a, b) => a.startedAt - b.startedAt) ?? []);
    }
    function extractEpisodeNumber(text) {
        if (typeof text === 'number') {
            return text;
        }
        if (text === undefined) {
            return undefined;
        }
        text = text.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 65248));
        const match = /\d+(\.\d+)?/.exec(text.replace(',', ''));
        if (match && hasMinLength(match, 1)) {
            return parseFloat(match[0]);
        }
        const kanjis = findKanjiNumbers_1(text);
        if (hasMinLength(kanjis, 1)) {
            return kanji2number_1(kanjis[0]);
        }
    }
    async function* fetchComments(providers, media, programs) {
        const promises = providers
            .map((provider) => programs.map(async (program) => provider
            .provide(media, program)
            .then((comments) => {
            if (comments.length === 0) {
                return [];
            }
            console.info(`[anime-comment-overlay] fetched ${comments.length} comments from ${provider.name}`);
            return comments.map((c) => ({
                id: c.id * c.providerId,
                vpos: c.vpos,
                content: c.content,
                date: c.date,
                date_usec: c.dateUsec,
                user_id: c.userId * c.providerId,
                owner: !c.userId,
                premium: c.isPremium,
                mail: c.mails,
                layer: c.layer,
            }));
        })
            .catch((e) => {
            console.error(`[anime-comment-overlay] failed to comments from ${provider.name}: ${e}`);
            return [];
        })))
            .flat();
        for (const promise of promises) {
            yield promise;
        }
    }

    async function fetchNiconicoJikkyoKakoLog({ channel, startTime, endTime, }) {
        const response = await fetch(`https://jikkyo.tsukumijima.net/api/kakolog/${channel}?starttime=${startTime}&endtime=${endTime}&format=json`);
        return response.json();
    }

    const NiconicoJikkyoKakoLogProvider = {
        name: 'ニコニコ実況過去ログ',
        async provide(media, program) {
            const jkId = program.channel.nicojkId;
            if (jkId === undefined) {
                return [];
            }
            const request = {
                channel: `jk${jkId}`,
                startTime: program.startedAt,
                endTime: program.endedAt,
            };
            const response = await fetchNiconicoJikkyoKakoLog(request);
            const chats = convertChats(response);
            const attr = ChannelCmAttributes[request.channel];
            if (media.video !== undefined) {
                console.info('[anime-comment-overlay] this media is video', media);
            }
            else if (attr === null) {
                console.info(`[anime-comment-overlay] channel ${request.channel} does not have CM`, program);
            }
            else {
                console.log(`[anime-comment-overlay] CM attribute for channel ${request.channel}`, attr, program);
                processHeadCms(chats, attr.head, program);
                for (const symbol of partSymbols) {
                    processIntervalCms(chats, symbol, attr.normal, attr.sponsor, program);
                }
            }
            let copyrightAdjustment = 0;
            const copyright = media.work?.copyright;
            if (copyright !== undefined) {
                const attr2 = copyrightCmAttributes.find((a) => a.pattern.test(copyright));
                if (attr2 !== undefined) {
                    copyrightAdjustment = attr2.adjustment;
                    console.info(`[anime-comment-overlay] copyright adjustment for ${copyright}: ${copyrightAdjustment}`, program);
                }
            }
            return (chats
                .filter((c) => !c.isDeleted)
                .map((c) => ({
                ...c,
                vpos: Math.max(copyrightAdjustment + (c.date - request.startTime) * 100 + Math.floor(c.dateUsec / 10000) - vposAdjustment, 0),
            })));
        },
    };
    function convertChats(response) {
        if ('error' in response) {
            console.error(`[anime-comment-overlay] received error from niconico jikkyo kako log: ${response.error}`);
            return [];
        }
        const users = [];
        return (response.packet
            .filter(({ chat }) => chat.deleted !== '1' && chat.abone !== '1')
            .map(({ chat }) => {
            const mails = chat.mail ? chat.mail.split(/\s+/g) : [];
            if (chat.content.startsWith('/')) {
                mails.push('invisible');
            }
            let userId = users.indexOf(chat.user_id);
            if (userId < 0) {
                userId = users.length;
                users.push(chat.user_id);
            }
            return {
                providerId: 1,
                id: parseInt(chat.no, 10),
                vpos: 0,
                content: chat.content,
                date: parseInt(chat.date, 10),
                dateUsec: chat.date_usec ? parseInt(chat.date_usec, 10) : Math.floor(Math.random() * 100000),
                userId,
                isPremium: chat.premium === '1',
                mails,
                layer: -1,
                isDeleted: false,
            };
        }));
    }
    function processHeadCms(comments, headInterval, program) {
        if (headInterval === 0) {
            return;
        }
        let removes = 0;
        const cmStartTime = program.startedAt;
        const cmEndTime = program.startedAt + headInterval;
        for (const comment of comments.filter((c) => cmStartTime < c.date && c.date <= cmEndTime)) {
            comment.isDeleted = true;
            removes++;
        }
        console.info(`[anime-comment-overlay] CM part: head (${removes} comments deleted)`, program);
        let shifts = 0;
        for (const comment of comments.filter((c) => cmEndTime < c.date)) {
            comment.date -= headInterval;
            shifts++;
        }
        console.info(`[anime-comment-overlay] CM part: head (${shifts} comments shifted)`, program);
    }
    function processIntervalCms(comments, symbol, normalInterval, sponsorInterval, program) {
        const partComments = comments.filter((c) => c.content === symbol);
        if (!hasMinLength(partComments, partSymbolCommentsThreshold)) {
            return;
        }
        if (partSymbols.indexOf(symbol) === 0) {
            const opComments = comments.filter((c) => opSymbols.includes(c.content));
            if (hasMinLength(opComments, opSymbolCommentsThreshold)) {
                const opStartTime = opComments[0].date;
                const opEndTime = opStartTime + opLength;
                if (opStartTime < partComments[0].date && partComments[0].date < opEndTime + opAdjustment) {
                    console.info(`[anime-comment-overlay] OP part: ${symbol}`, program);
                    return;
                }
            }
        }
        let removes = 0;
        const effectiveCmLength = normalInterval + (partSymbols.indexOf(symbol) === 0 ? sponsorInterval : 0);
        const cmEndTime = partComments[0].date - partSymbolAdjustment;
        const cmStartTime = cmEndTime - effectiveCmLength;
        for (const comment of comments.filter((c) => cmStartTime < c.date && c.date <= cmEndTime)) {
            comment.isDeleted = true;
            removes++;
        }
        console.info(`[anime-comment-overlay] CM part: ${symbol} (${removes} comments deleted)`, program);
        let shifts = 0;
        for (const comment of comments.filter((c) => cmEndTime < c.date)) {
            comment.date -= effectiveCmLength;
            shifts++;
        }
        console.info(`[anime-comment-overlay] CM part: ${symbol} (${shifts} comments shifted)`, program);
    }

    const overlays = [
        DanimeOverlay,
        AbemaVideoOverlay,
        NetflixOverlay,
        EpgStationOnAirOverlay,
        EpgStationRecordedOverlay,
    ];
    const providers = [NiconicoJikkyoKakoLogProvider];
    async function initializeOverlay(overlay, params) {
        const media = await overlay.detectMedia(...params);
        console.log('[anime-comment-overlay] media', media);
        const programs = await findPrograms(media);
        console.log('[anime-comment-overlay] programs', programs);
        const { video, canvas } = overlay.initializeContainers();
        const renderer = new NiconiComments(canvas, undefined, {
            format: 'empty',
        });
        let isInitialized = false;
        let cachedVideo = null;
        const interval = window.setInterval(() => {
            if (!isInitialized) {
                return;
            }
            let time;
            if (typeof video === 'function') {
                if (cachedVideo?.isConnected !== true) {
                    cachedVideo = video();
                    if (cachedVideo === null) {
                        return;
                    }
                }
                time = cachedVideo.currentTime;
            }
            else {
                time = video.currentTime;
            }
            setTimeout(() => {
                const vpos = Math.floor(time * 100);
                renderer.drawCanvas(vpos);
            }, 0);
        }, 1000 / targetFps);
        function onMediaChanged() {
            overlay.removeEventListener('mediaChanged', onMediaChanged);
            clearInterval(interval);
            renderer.clear();
            canvas.remove();
            initializeOverlays().catch(console.error);
            console.info('[anime-comment-overlay] media changed');
        }
        overlay.addEventListener('mediaChanged', onMediaChanged);
        for await (const comments of fetchComments(providers, media, programs.slice(0, maxPrograms))) {
            setTimeout(() => {
                renderer.addComments(...comments);
            }, 0);
        }
        isInitialized = true;
    }
    async function initializeOverlays() {
        for (const overlay of overlays) {
            const params = overlay.url.exec(window.location.href)?.slice(1);
            if (params === undefined) {
                continue;
            }
            console.info(`[anime-comment-overlay] initializing ${overlay.id}`, params);
            await initializeOverlay(overlay, params);
            console.info(`[anime-comment-overlay] initialized ${overlay.id}`, params);
            break;
        }
    }
    initializeOverlays().catch(console.error);

})(NiconiComments, fxp);