Greasy Fork

Abema ニコニコ風コメント (Beta)

Abemaのコメントをニコニコ風に流すやつ

当前为 2022-05-01 提交的版本,查看 最新版本

// ==UserScript==
// @name         Abema ニコニコ風コメント (Beta)
// @namespace    https://midra.me
// @version      0.0.1
// @description  Abemaのコメントをニコニコ風に流すやつ
// @author       Midra
// @license      MIT
// @match        https://abema.tv/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=abema.tv
// @run-at       document-body
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @compatible   chrome >=84
// @compatible   safari >=15
// @require      https://greasyfork.org/scripts/444119-flowcomments/code/FlowComments.js?version=1046016
// ==/UserScript==

// @ts-check
/* jshint esversion: 6 */

'use strict'

/****************************************
 * FlowComments for Abema
 ****************************************/
{
  //----------------------------------------
  // FlowComments
  //----------------------------------------
  const fc = new FlowComments({
    autoResize: true,
    autoAdjRes: true,
  })
  // @ts-ignore
  unsafeWindow.mid_fc = fc

  /**
   * @typedef   {object} AbmSlotStats
   * @property  {number} view     視聴数
   * @property  {number} comment  コメント数
   */

  /****************************************
   * @classdesc Abemaのコメント
   */
  class AbmComment {
    /**
     * コメントID
     * @type {string}
     */
    #id
    /**
     * ユーザーID
     * @type {string}
     */
    #userId
    /**
     * コメント本文
     * @type {string}
     */
    #message
    /**
     * 投稿日時
     * @type {number}
     */
    #createdAtMs

    /****************************************
     * コンストラクタ
     * @param {object} comment コメント
     */
    constructor(comment) {
      this.#id = comment.id
      this.#userId = comment.userId
      this.#message = comment.message
      this.#createdAtMs = comment.createdAtMs
    }

    get id() { return this.#id }
    get userId() { return this.#userId }
    get message() { return this.#message }
    get createdAtMs() { return this.#createdAtMs }
  }

  /****************************************
   * @classdesc Abemaの放送枠
   */
  class AbmSlot {
    /**
     * ID
     * @type {string}
     */
    id
    /**
     * チャンネルID
     * @type {string}
     */
    channelId
    /**
     * タイトル
     * @type {string}
     */
    title
    /**
     * 放送開始日時
     * @type {number}
     */
    startAt
    /**
     * 放送終了日時
     * @type {number}
     */
    endAt
    /**
     * 視聴数、コメント数
     * @type {AbmSlotStats}
     */
    stats

    /****************************************
     * コンストラクタ
     * @param {object} slot 放送枠(API)
     */
    constructor(slot) {
      this.id = slot.id
      this.channelId = slot.channelId
      this.title = slot.title
      this.startAt = slot.startAt
      this.endAt = slot.endAt
      this.stats = slot.stats
    }

    /**
     * 放送中かどうか
     * @type {boolean}
     */
    get isOnair() {
      if (Number.isFinite(this.startAt) && Number.isFinite(this.endAt)) {
        const now = Date.now()
        const isStarted = this.startAt * 1000 <= now
        const isEnded = this.endAt * 1000 < now
        return isStarted && !isEnded
      } else {
        return false
      }
    }
  }

  /****************************************
   * ABMCMTオブジェクト
   */
  const ABMCMT = {}

  /****************************************
   * キャッシュ
   */
  ABMCMT.temp = {
    /**
     * .com-a-Video__video
     * @type {HTMLElement}
     */
    video: null,
    /**
     * チャンネル情報一覧みたいなやつ
     * @type {Array<AbmSlot>}
     */
    slots: [],
    /**
     * 最新の取得済みコメント
     * @type {AbmComment}
     */
    latestComment: null,

    /****************************************
     * cacheリセット
     */
    reset() {
      this.video = null
      this.slots = []
      this.latestComment = null
    },
  }

  /****************************************
   * 設定関連
   */
  ABMCMT.config = {
    /**
     * コメント取得間隔
     * @type {number}
     */
    INTERVAL_TIME: 7000,

    /**
     * リクエストのオプション
     * @type {RequestInit}
     */
    get requestInit() {
      return {
        method: 'GET',
        mode: 'cors',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `bearer ${window.localStorage.getItem('abm_token')}`,
        },
        referrerPolicy: 'strict-origin-when-cross-origin',
      }
    },

    /****************************************
     * API関連
     */
    api: {
      base: 'https://api.abema.io',

      /**
       * 放送中のチャンネル情報一覧みたいなやつ
       * @type {string}
       */
      get broadcastSlots() { return `${this.base}/v1/broadcast/slots` },

      /****************************************
       * コメント取得
       * @param {string} slotId   放送枠のID
       * @param {number} [since]  `since`以降のコメントを取得
       * @param {number} [limit]  取得するコメント数上限
       * @returns {string} URL
       */
      comments(slotId, since, limit = 100) {
        /** @type {Record<string, string>} */
        const params = {}
        if (Number.isFinite(since)) {
          params.since = since.toFixed()
        }
        if (Number.isFinite(limit)) {
          params.limit = limit.toFixed()
        }
        return `${this.base}/v1/slots/${slotId}/comments?${new URLSearchParams(params).toString()}`
      },
    },
  }

  /****************************************
   * ユーティリティ
   */
  ABMCMT.util = {
    /****************************************
     * 放送枠関連
     */
    slot: {
      /****************************************
       * 放送枠一覧を取得(都度更新)
       * @returns {Promise<Array<AbmSlot>>} 放送枠一覧
       */
      async fetch() {
        const res = await fetch(ABMCMT.config.api.broadcastSlots)
        const { slots } = await res.json()
        if (Array.isArray(slots) && slots.length !== 0) {
          ABMCMT.temp.slots = slots.map(v => new AbmSlot(v))
          return ABMCMT.temp.slots
        } else {
          throw new Error('slots is empty')
        }
      },

      /****************************************
       * 放送枠一覧を取得
       * @returns {Promise<Array<AbmSlot>>} 放送枠一覧
       */
      async getAll() {
        if (ABMCMT.temp.slots.length !== 0) {
          return ABMCMT.temp.slots
        } else {
          return await this.fetch()
        }
      },

      /****************************************
       * チャンネルIDを指定して放送枠を取得
       * @param {string} channelId    チャンネルID
       * @returns {Promise<AbmSlot>}  放送枠
       */
      async getByChannelId(channelId) {
        let slots = await this.getAll()
        let slot = slots.find(v => v.isOnair && v.channelId === channelId)
        // 見つからなかった場合、最新の放送枠を取得して探す
        if (slot === undefined) {
          slots = await this.fetch()
          slot = slots.find(v => v.isOnair && v.channelId === channelId)
        }
        return slot
      },

      /****************************************
       * 現在再生中の放送枠を取得
       * @returns {Promise<AbmSlot>} 放送枠
       */
      async getNow() {
        const nowSlot = await this.getByChannelId(this.getNowChannelId())
        if (nowSlot === undefined) {
          throw new Error('nowSlot is undefined')
        }
        return nowSlot
      },

      /****************************************
       * 現在再生中のチャンネルIDを取得
       * @returns {string} チャンネルID
       */
      getNowChannelId() {
        let url = window.location.href
        if (url === 'https://abema.tv/' && ABMCMT.temp.video !== null) {
          const preview = ABMCMT.temp.video.closest('.com-TvAreaOfPreviewLayout__content')
          const link = preview?.getElementsByClassName('com-a-Link')[0]
          url = link instanceof HTMLAnchorElement ? link.href : undefined
        }
        if (typeof url === 'string' && url.startsWith('https://abema.tv/now-on-air/')) {
          return url.match(/https:\/\/abema\.tv\/now-on-air\/(\S+)/)[1]
        } else if (url === undefined) {
          throw new Error('url is undefined')
        } else {
          throw new Error('unknown error')
        }
      },
    },

    /****************************************
     * コメント関連
     */
    comment: {
      /****************************************
       * コメントを取得
       * @param {string} slotId   放送枠のID
       * @param {number} [since]  `since`以降のコメントを取得
       * @param {number} [limit]  取得するコメント数上限
       * @returns {Promise<Array<AbmComment>>} コメント一覧
       */
      async fetch(slotId, since, limit) {
        if (slotId === undefined) {
          throw new Error('slotId is undefined')
        }
        const res = await fetch(
          ABMCMT.config.api.comments(slotId, since, limit),
          ABMCMT.config.requestInit
        )
        const { comments } = await res.json()
        if (Array.isArray(comments)) {
          return comments.map(v => new AbmComment(v))
        } else {
          return []
        }
      },

      /****************************************
       * 新着コメントを取得
       * @returns {Promise<Array<AbmComment>>} コメント一覧
       */
      async fetchNewComments() {
        const nowSlot = await ABMCMT.util.slot.getNow()
        const since = ABMCMT.temp.latestComment?.createdAtMs
        const comments = await this.fetch(
          nowSlot.id,
          Number.isFinite(since) ? (since + 1) : null
        )
        if (comments.length !== 0) {
          ABMCMT.temp.latestComment = comments[0]
        }
        return comments
      },
    },
  }

  /****************************************
   * 初期化
   * @param {HTMLElement} [video] Video要素
   */
  ABMCMT.initialize = async function(video) {
    // キャッシュをリセット
    this.temp.reset()

    if (video !== undefined) {
      this.temp.video = video
      if (document.getElementsByClassName('mid-FlowCommentsArea').length === 0) {
        // FlowCommentsのCanvas追加
        this.temp.video.insertAdjacentElement('afterend', fc.canvas)
      }
    }

    // チャンネル情報一覧を取得
    await this.util.slot.fetch()
    // 一旦最新のコメントを取得しておく
    await this.util.comment.fetchNewComments()
  }

  /****************************************
   * 定期的に実行するやつ
   */
  ABMCMT.scheduler = {
    _id: null,

    /****************************************
     * ループ中に実行される処理
     * @return {Promise}
     */
    async update() {
      try {
        const comments = await ABMCMT.util.comment.fetchNewComments()
        if (2 <= comments.length) {
          const latest = comments[0].createdAtMs
          const oldest = comments.slice(-1)[0].createdAtMs
          const diff = latest - oldest
          comments.forEach(val => {
            const timeout = (ABMCMT.config.INTERVAL_TIME - 2000) * (val.createdAtMs - oldest) / diff
            const id = this._id
            setTimeout(() => {
              if (id === this._id) {
                fc.pushComment(new FlowCommentItem(val.id, val.message))
              }
            }, timeout)
          })
        } else if (1 <= comments.length) {
          fc.pushComment(new FlowCommentItem(comments[0].id, comments[0].message))
        }
      } catch (e) {
        console.error(e)
      }
    },

    /****************************************
     * ループ処理
     */
    loop() {
      this.update()
    },

    /****************************************
     * 開始
     */
    start() {
      if (this._id === null) {
        fc.start()
        this.loop()
        this._id = window.setInterval(async () => {
          this.loop()
        }, ABMCMT.config.INTERVAL_TIME)
      }
    },

    /****************************************
     * 停止
     */
    stop() {
      if (this._id !== null) {
        fc.initialize()
        window.clearInterval(this._id)
        this._id = null
      }
    },
  }

  // @ts-ignore
  unsafeWindow.ABMCMT = ABMCMT

  //----------------------------------------
  // .com-a-Video__video を取得する
  //----------------------------------------
  const obs_target = document.body
  const obs_options = { childList: true, subtree: true }
  const obs = new MutationObserver(async mutationsList => {
    obs.disconnect()

    for (const { addedNodes, removedNodes } of mutationsList) {
      // 追加
      for (const added of addedNodes) {
        if (!(added instanceof HTMLElement)) continue
        if (added.classList.contains('com-a-Video__video')) {
          try {
            await ABMCMT.initialize(added)
            ABMCMT.scheduler.start()
          } catch (e) {
            console.error(e)
          }
        }
      }

      // 削除
      for (const removed of removedNodes) {
        if (!(removed instanceof HTMLElement)) continue
        if (removed === ABMCMT.temp.video) {
          ABMCMT.scheduler.stop()
        }
      }
    }

    obs.observe(obs_target, obs_options)
  })
  obs.observe(obs_target, obs_options)

  //----------------------------------------
  // CSS
  //----------------------------------------
  const style =
  `
  .mid-FlowComments {
    position: absolute;
    width: 100%;
    height: 100%;
    pointer-events: none;
  }
  `
  document.head.insertAdjacentHTML('beforeend',
    `<style id="mid-FlowComments-style">${style}</style>`
  )
}